diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1d8d619..c5e527dc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,10 @@ jobs: working-directory: crates/agent-gateway/web run: pnpm build + - name: Lint WebUI + working-directory: crates/agent-gateway/web + run: pnpm lint + - name: Test WebUI modules working-directory: crates/agent-gateway run: node --test test/webui/*.test.mjs web/test/*.test.mjs @@ -138,6 +142,14 @@ jobs: working-directory: crates/agent-gui run: pnpm install --frozen-lockfile + - name: Typecheck and build GUI frontend + working-directory: crates/agent-gui + run: pnpm build + + - name: Lint GUI frontend + working-directory: crates/agent-gui + run: pnpm lint + - name: Test frontend modules working-directory: crates/agent-gui run: pnpm test:frontend @@ -179,6 +191,19 @@ jobs: CARGO_TERM_COLOR: always run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml chat_history --lib + mirror: + name: GUI/WebUI Mirror Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Check mirrored files are byte-identical + run: node scripts/check-mirror.mjs + whitespace: name: Diff Hygiene runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 14db4cabd..0cf50979d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,6 +1880,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2840,6 +2849,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea94e891b3606826e9c998be69ddca42247dad8ad50b1649a5cb7e1c9ae06fd" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3063,6 +3092,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3181,6 +3230,7 @@ dependencies = [ "globset", "ignore", "lopdf", + "notify", "objc2-app-kit 0.3.2", "portable-pty", "prost", @@ -3385,6 +3435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3538,6 +3589,33 @@ dependencies = [ "nom", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "num-bigint" version = "0.4.6" diff --git a/crates/agent-gateway/cmd/gateway/main.go b/crates/agent-gateway/cmd/gateway/main.go index 9f0dbb3db..358236bf5 100644 --- a/crates/agent-gateway/cmd/gateway/main.go +++ b/crates/agent-gateway/cmd/gateway/main.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" "google.golang.org/grpc/reflection" "github.com/liveagent/agent-gateway/internal/auth" @@ -26,19 +27,7 @@ const grpcShutdownTimeout = 3 * time.Second func main() { cfg := config.Load() - chatEventStore, err := session.OpenSQLiteChatEventStore(cfg.ChatEventStorePath) - if err != nil { - log.Fatalf("open chat event store: %v", err) - } - defer func() { - if err := chatEventStore.Close(); err != nil { - log.Printf("close chat event store: %v", err) - } - }() - sm, err := session.NewManagerWithChatEventStore(chatEventStore) - if err != nil { - log.Fatalf("initialize session manager: %v", err) - } + sm := session.NewManager() grpcServer, err := newGRPCServer(cfg, sm) if err != nil { @@ -111,6 +100,18 @@ func newGRPCServer(cfg *config.Config, sm *session.Manager) (*grpc.Server, error grpc.MaxSendMsgSize(cfg.GRPCMaxMessageBytes), grpc.UnaryInterceptor(auth.GRPCUnaryInterceptor(cfg.Token)), grpc.StreamInterceptor(auth.GRPCStreamInterceptor(cfg.Token)), + // Transport-level liveness: h2 PINGs are not subject to application + // queue congestion, so dead links are detected even mid-stream. + grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: 30 * time.Second, + Timeout: 10 * time.Second, + }), + // The desktop GUI pings every 10-60s; MinTime must stay below its + // floor or grpc-go answers with a too_many_pings GOAWAY. + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ + MinTime: 5 * time.Second, + PermitWithoutStream: true, + }), } if cfg.TLSCert != "" || cfg.TLSKey != "" { creds, err := credentials.NewServerTLSFromFile(cfg.TLSCert, cfg.TLSKey) diff --git a/crates/agent-gateway/go.mod b/crates/agent-gateway/go.mod index 4582db628..593aa80b9 100644 --- a/crates/agent-gateway/go.mod +++ b/crates/agent-gateway/go.mod @@ -14,15 +14,7 @@ require ( ) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.27.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect - modernc.org/libc v1.72.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.52.0 // indirect ) diff --git a/crates/agent-gateway/go.sum b/crates/agent-gateway/go.sum index 5e43de4a9..01a032846 100644 --- a/crates/agent-gateway/go.sum +++ b/crates/agent-gateway/go.sum @@ -1,7 +1,5 @@ github.com/doyensec/safeurl v0.2.5 h1:kKu0JNQy0tJ8jkDyB5h6Aml9vWWniq+mpoa12EGLcOQ= github.com/doyensec/safeurl v0.2.5/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -16,12 +14,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= @@ -40,9 +32,6 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= @@ -55,11 +44,3 @@ google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= -modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/crates/agent-gateway/internal/chatwire/payloads.go b/crates/agent-gateway/internal/chatwire/payloads.go new file mode 100644 index 000000000..567e51529 --- /dev/null +++ b/crates/agent-gateway/internal/chatwire/payloads.go @@ -0,0 +1,158 @@ +// Package chatwire shapes agent chat protobuf events into the JSON payloads +// sent to webui clients. Shaping (decode, normalize, result trimming) happens +// exactly once at ingress so every subscriber observes identical bytes. +// +// Tool-call arguments pass through untouched: the desktop app is the single +// producer of streaming previews (truncated text + __liveagent_stream_preview +// metadata) and the gateway must never recompute or overwrite them. +package chatwire + +import ( + "encoding/json" + "strings" + "unicode/utf8" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// EventPayload shapes a ChatEvent into a wire payload, decoding the JSON data +// blob and trimming oversized tool-result content. +func EventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { + protoType := EventTypeName(event.GetType()) + payload := map[string]any{ + "type": protoType, + } + if seq > 0 { + payload["seq"] = seq + } + if len(workdirInput) > 0 { + if workdir := strings.TrimSpace(workdirInput[0]); workdir != "" { + payload["workdir"] = workdir + } + } + + raw := strings.TrimSpace(event.GetData()) + if raw == "" { + raw = "{}" + } + + var decoded map[string]any + if err := json.Unmarshal([]byte(raw), &decoded); err == nil { + for key, value := range decoded { + payload[key] = value + } + } + + if conversationID := strings.TrimSpace(event.GetConversationId()); conversationID != "" { + payload["conversation_id"] = conversationID + } + + TrimLargeToolResultContent(payload, protoType) + + return payload +} + +const toolResultMaxBytes = 200 + +// TrimLargeToolResultContent truncates oversized tool-result content in place, +// attaching a __liveagent_stream_preview meta block describing the original +// size. Tool-call arguments are never touched. +func TrimLargeToolResultContent(payload map[string]any, protoType string) { + eventType, _ := payload["type"].(string) + if eventType != "tool_result" && protoType != "tool_result" { + return + } + switch content := payload["content"].(type) { + case string: + if len(content) > toolResultMaxBytes { + payload["content"] = truncateRuneSafe(content, toolResultMaxBytes) + setPreviewMeta(payload, "content", content) + } + case []any: + for _, item := range content { + block, ok := item.(map[string]any) + if !ok { + continue + } + if text, ok := block["text"].(string); ok && len(text) > toolResultMaxBytes { + block["text"] = truncateRuneSafe(text, toolResultMaxBytes) + setPreviewMeta(block, "text", text) + } + } + } +} + +// truncateRuneSafe cuts s to at most maxBytes without splitting a UTF-8 rune. +func truncateRuneSafe(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s + } + cut := maxBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} + +func setPreviewMeta(container map[string]any, fieldName string, original string) { + const metaKey = "__liveagent_stream_preview" + meta, _ := container[metaKey].(map[string]any) + if meta == nil { + meta = map[string]any{} + container[metaKey] = meta + } + fields, _ := meta["fields"].(map[string]any) + if fields == nil { + fields = map[string]any{} + meta["fields"] = fields + } + fields[fieldName] = map[string]any{ + "chars": utf8.RuneCountInString(original), + "lines": countLines(original), + "truncated": true, + } +} + +func countLines(s string) int { + if len(s) == 0 { + return 0 + } + n := 1 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + n++ + } else if s[i] == '\r' { + n++ + if i+1 < len(s) && s[i+1] == '\n' { + i++ + } + } + } + return n +} + +// EventTypeName maps the protobuf ChatEvent type enum to its wire name. +func EventTypeName(eventType gatewayv1.ChatEvent_ChatEventType) string { + switch eventType { + case gatewayv1.ChatEvent_TOKEN: + return "token" + case gatewayv1.ChatEvent_THINKING: + return "thinking" + case gatewayv1.ChatEvent_TOOL_CALL: + return "tool_call" + case gatewayv1.ChatEvent_TOOL_RESULT: + return "tool_result" + case gatewayv1.ChatEvent_DONE: + return "done" + case gatewayv1.ChatEvent_ERROR: + return "error" + case gatewayv1.ChatEvent_TOOL_STATUS: + return "tool_status" + case gatewayv1.ChatEvent_HOSTED_SEARCH: + return "hosted_search" + case gatewayv1.ChatEvent_USER_MESSAGE: + return "user_message" + default: + return "message" + } +} diff --git a/crates/agent-gateway/internal/chatwire/payloads_test.go b/crates/agent-gateway/internal/chatwire/payloads_test.go new file mode 100644 index 000000000..f2f17251f --- /dev/null +++ b/crates/agent-gateway/internal/chatwire/payloads_test.go @@ -0,0 +1,141 @@ +package chatwire + +import ( + "strings" + "testing" + "unicode/utf8" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func TestEventPayloadPreservesHostedSearch(t *testing.T) { + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_HOSTED_SEARCH, + ConversationId: "conversation-1", + Data: `{"id":"search-1","provider":"codex","status":"completed","queries":["设计模式定义"],"sources":[{"url":"https://example.com/pattern","title":"设计模式"}],"round":2}`, + }, 7) + + if payload["type"] != "hosted_search" { + t.Fatalf("expected hosted_search type, got %#v", payload["type"]) + } + if payload["conversation_id"] != "conversation-1" { + t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) + } + if payload["id"] != "search-1" { + t.Fatalf("expected search id, got %#v", payload["id"]) + } + if payload["provider"] != "codex" { + t.Fatalf("expected provider, got %#v", payload["provider"]) + } + if payload["status"] != "completed" { + t.Fatalf("expected status, got %#v", payload["status"]) + } + if payload["seq"] != int64(7) { + t.Fatalf("expected seq 7, got %#v", payload["seq"]) + } +} + +func TestEventPayloadPreservesToolCallDeltaType(t *testing.T) { + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOOL_CALL, + ConversationId: "conversation-1", + Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"con"},"round":1}`, + }, 8) + + if payload["type"] != "tool_call_delta" { + t.Fatalf("expected tool_call_delta type, got %#v", payload["type"]) + } + if payload["conversation_id"] != "conversation-1" { + t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) + } + if payload["id"] != "call-write" { + t.Fatalf("expected tool call id, got %#v", payload["id"]) + } + if payload["name"] != "Write" { + t.Fatalf("expected tool name, got %#v", payload["name"]) + } + if payload["seq"] != int64(8) { + t.Fatalf("expected seq 8, got %#v", payload["seq"]) + } +} + +// Tool-call arguments must pass through untouched: the desktop app already +// truncated them and stamped the preview meta; the gateway rewriting either +// caused the chars regression this suite guards against. +func TestEventPayloadLeavesToolCallArgsUntouched(t *testing.T) { + longContent := strings.Repeat("x", 500) + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOOL_CALL, + ConversationId: "conversation-1", + Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"` + + longContent + + `","__liveagent_stream_preview":{"v":2,"progress":6000,"fields":{"content":{"chars":6000,"lines":12,"truncated":true}}}},"round":1}`, + }, 9) + + args, ok := payload["arguments"].(map[string]any) + if !ok { + t.Fatalf("expected arguments map, got %#v", payload["arguments"]) + } + if content := args["content"].(string); content != longContent { + t.Fatalf("content modified: len=%d, want %d", len(content), len(longContent)) + } + meta, ok := args["__liveagent_stream_preview"].(map[string]any) + if !ok { + t.Fatalf("expected producer preview meta preserved, got %#v", args) + } + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if chars, _ := info["chars"].(float64); chars != 6000 { + t.Fatalf("producer chars overwritten: %#v", info) + } + if progress, _ := meta["progress"].(float64); progress != 6000 { + t.Fatalf("producer progress overwritten: %#v", meta) + } +} + +func TestTrimLargeToolResultContentTruncatesToolResult(t *testing.T) { + longText := strings.Repeat("r", 300) + payload := map[string]any{ + "type": "tool_result", + "content": longText, + } + + TrimLargeToolResultContent(payload, "tool_result") + + if content := payload["content"].(string); len(content) != 200 { + t.Fatalf("trimmed result length = %d, want 200", len(content)) + } + meta, ok := payload["__liveagent_stream_preview"].(map[string]any) + if !ok { + t.Fatalf("expected preview meta on tool_result payload") + } + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if info["chars"] != 300 || info["truncated"] != true { + t.Fatalf("preview meta = %#v", info) + } +} + +func TestTrimLargeToolResultContentIsRuneSafe(t *testing.T) { + longText := strings.Repeat("汉", 100) // 300 bytes, 100 runes + payload := map[string]any{ + "type": "tool_result", + "content": longText, + } + + TrimLargeToolResultContent(payload, "tool_result") + + content := payload["content"].(string) + if !utf8.ValidString(content) { + t.Fatalf("truncated content is not valid UTF-8") + } + if len(content) > 200 { + t.Fatalf("trimmed result length = %d, want <= 200", len(content)) + } + meta := payload["__liveagent_stream_preview"].(map[string]any) + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if info["chars"] != 100 { + t.Fatalf("chars should count runes, got %#v", info["chars"]) + } +} diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index 5b50b12fe..e3d1c225b 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -22,8 +22,10 @@ type Config struct { HeartbeatPeriod time.Duration WebSocketHeartbeatPeriod time.Duration WebSocketWriteTimeout time.Duration + WebSocketWriteQueueSize int GRPCMaxMessageBytes int - ChatEventStorePath string + RelayBufferSeconds int + CommandQueueTimeout time.Duration } func Load() *Config { @@ -40,14 +42,15 @@ func Load() *Config { flag.DurationVar(&cfg.HeartbeatPeriod, "heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_HEARTBEAT_PERIOD", 30*time.Second), "ping interval for agent connection") flag.DurationVar(&cfg.WebSocketHeartbeatPeriod, "websocket-heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_WS_HEARTBEAT_PERIOD", 15*time.Second), "ping interval for browser WebSocket connections") flag.DurationVar(&cfg.WebSocketWriteTimeout, "websocket-write-timeout", getenvDuration("LIVEAGENT_GATEWAY_WS_WRITE_TIMEOUT", 10*time.Second), "write timeout for browser WebSocket connections") + flag.IntVar(&cfg.WebSocketWriteQueueSize, "websocket-write-queue-size", getenvInt("LIVEAGENT_GATEWAY_WS_WRITE_QUEUE_SIZE", 512), "write queue buffer size for browser WebSocket connections") flag.IntVar(&cfg.GRPCMaxMessageBytes, "grpc-max-message-bytes", getenvInt("LIVEAGENT_GATEWAY_GRPC_MAX_MESSAGE_BYTES", DefaultGRPCMaxMessageBytes), "maximum gRPC message size in bytes") - flag.StringVar(&cfg.ChatEventStorePath, "chat-event-store", getenv("LIVEAGENT_GATEWAY_CHAT_EVENT_STORE", defaultChatEventStorePath()), "SQLite path for durable chat command/event replay state") + flag.IntVar(&cfg.RelayBufferSeconds, "relay-buffer-seconds", getenvInt("LIVEAGENT_GATEWAY_RELAY_BUFFER_SECONDS", 30), "seconds of chat events to buffer for brief reconnections") + flag.DurationVar(&cfg.CommandQueueTimeout, "command-queue-timeout", getenvDuration("LIVEAGENT_GATEWAY_COMMAND_QUEUE_TIMEOUT", 30*time.Second), "timeout for queuing commands when agent is temporarily offline") flag.Parse() cfg.Token = strings.TrimSpace(cfg.Token) cfg.TLSCert = strings.TrimSpace(cfg.TLSCert) cfg.TLSKey = strings.TrimSpace(cfg.TLSKey) - cfg.ChatEventStorePath = strings.TrimSpace(cfg.ChatEventStorePath) if cfg.Token == "" { flag.Usage() @@ -68,18 +71,19 @@ func Load() *Config { if cfg.WebSocketWriteTimeout <= 0 { cfg.WebSocketWriteTimeout = 10 * time.Second } + if cfg.WebSocketWriteQueueSize <= 0 { + cfg.WebSocketWriteQueueSize = 512 + } + if cfg.RelayBufferSeconds <= 0 { + cfg.RelayBufferSeconds = 30 + } + if cfg.CommandQueueTimeout <= 0 { + cfg.CommandQueueTimeout = 30 * time.Second + } return cfg } -func defaultChatEventStorePath() string { - configDir, err := os.UserConfigDir() - if err != nil || strings.TrimSpace(configDir) == "" { - return "liveagent-gateway-chat.sqlite3" - } - return configDir + string(os.PathSeparator) + "LiveAgent" + string(os.PathSeparator) + "gateway-chat.sqlite3" -} - func getenv(key, fallback string) string { if value := os.Getenv(key); value != "" { return value diff --git a/crates/agent-gateway/internal/config/config_test.go b/crates/agent-gateway/internal/config/config_test.go index ee0c04c02..57a0616e5 100644 --- a/crates/agent-gateway/internal/config/config_test.go +++ b/crates/agent-gateway/internal/config/config_test.go @@ -11,8 +11,6 @@ func TestLoadNormalizesTokenAndTLSPaths(t *testing.T) { t.Setenv("LIVEAGENT_GATEWAY_TOKEN", " secret-token\r\n") t.Setenv("LIVEAGENT_GATEWAY_TLS_CERT", " cert.pem ") t.Setenv("LIVEAGENT_GATEWAY_TLS_KEY", "\tkey.pem\r\n") - t.Setenv("LIVEAGENT_GATEWAY_CHAT_EVENT_STORE", " /tmp/liveagent/gateway-chat.sqlite3 ") - resetFlagsForTest(t) cfg := Load() if cfg.Token != "secret-token" { @@ -24,9 +22,6 @@ func TestLoadNormalizesTokenAndTLSPaths(t *testing.T) { if cfg.TLSKey != "key.pem" { t.Fatalf("TLSKey = %q, want %q", cfg.TLSKey, "key.pem") } - if cfg.ChatEventStorePath != "/tmp/liveagent/gateway-chat.sqlite3" { - t.Fatalf("ChatEventStorePath = %q, want trimmed path", cfg.ChatEventStorePath) - } } func TestLoadUsesRailwayPortForHTTPDefault(t *testing.T) { diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 0dd2b4700..f0a48c740 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -31,11 +31,15 @@ const ( TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START TunnelFrameKind = 4 TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY TunnelFrameKind = 5 TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END TunnelFrameKind = 6 - TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN TunnelFrameKind = 7 - TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME TunnelFrameKind = 8 - TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE TunnelFrameKind = 9 - TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR TunnelFrameKind = 10 - TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL TunnelFrameKind = 11 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL TunnelFrameKind = 7 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_OK TunnelFrameKind = 8 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_ERROR TunnelFrameKind = 9 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME TunnelFrameKind = 10 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE TunnelFrameKind = 11 + TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR TunnelFrameKind = 12 + TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL TunnelFrameKind = 13 + TunnelFrameKind_TUNNEL_FRAME_KIND_PING TunnelFrameKind = 14 + TunnelFrameKind_TUNNEL_FRAME_KIND_PONG TunnelFrameKind = 15 ) // Enum value maps for TunnelFrameKind. @@ -48,11 +52,15 @@ var ( 4: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START", 5: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY", 6: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END", - 7: "TUNNEL_FRAME_KIND_WS_OPEN", - 8: "TUNNEL_FRAME_KIND_WS_FRAME", - 9: "TUNNEL_FRAME_KIND_WS_CLOSE", - 10: "TUNNEL_FRAME_KIND_ERROR", - 11: "TUNNEL_FRAME_KIND_CANCEL", + 7: "TUNNEL_FRAME_KIND_WS_DIAL", + 8: "TUNNEL_FRAME_KIND_WS_DIAL_OK", + 9: "TUNNEL_FRAME_KIND_WS_DIAL_ERROR", + 10: "TUNNEL_FRAME_KIND_WS_FRAME", + 11: "TUNNEL_FRAME_KIND_WS_CLOSE", + 12: "TUNNEL_FRAME_KIND_ERROR", + 13: "TUNNEL_FRAME_KIND_CANCEL", + 14: "TUNNEL_FRAME_KIND_PING", + 15: "TUNNEL_FRAME_KIND_PONG", } TunnelFrameKind_value = map[string]int32{ "TUNNEL_FRAME_KIND_UNSPECIFIED": 0, @@ -62,11 +70,15 @@ var ( "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START": 4, "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY": 5, "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END": 6, - "TUNNEL_FRAME_KIND_WS_OPEN": 7, - "TUNNEL_FRAME_KIND_WS_FRAME": 8, - "TUNNEL_FRAME_KIND_WS_CLOSE": 9, - "TUNNEL_FRAME_KIND_ERROR": 10, - "TUNNEL_FRAME_KIND_CANCEL": 11, + "TUNNEL_FRAME_KIND_WS_DIAL": 7, + "TUNNEL_FRAME_KIND_WS_DIAL_OK": 8, + "TUNNEL_FRAME_KIND_WS_DIAL_ERROR": 9, + "TUNNEL_FRAME_KIND_WS_FRAME": 10, + "TUNNEL_FRAME_KIND_WS_CLOSE": 11, + "TUNNEL_FRAME_KIND_ERROR": 12, + "TUNNEL_FRAME_KIND_CANCEL": 13, + "TUNNEL_FRAME_KIND_PING": 14, + "TUNNEL_FRAME_KIND_PONG": 15, } ) @@ -97,6 +109,55 @@ func (TunnelFrameKind) EnumDescriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{0} } +type TunnelWsMessageType int32 + +const ( + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED TunnelWsMessageType = 0 + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT TunnelWsMessageType = 1 + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_BINARY TunnelWsMessageType = 2 +) + +// Enum value maps for TunnelWsMessageType. +var ( + TunnelWsMessageType_name = map[int32]string{ + 0: "TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED", + 1: "TUNNEL_WS_MESSAGE_TYPE_TEXT", + 2: "TUNNEL_WS_MESSAGE_TYPE_BINARY", + } + TunnelWsMessageType_value = map[string]int32{ + "TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED": 0, + "TUNNEL_WS_MESSAGE_TYPE_TEXT": 1, + "TUNNEL_WS_MESSAGE_TYPE_BINARY": 2, + } +) + +func (x TunnelWsMessageType) Enum() *TunnelWsMessageType { + p := new(TunnelWsMessageType) + *p = x + return p +} + +func (x TunnelWsMessageType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TunnelWsMessageType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() +} + +func (TunnelWsMessageType) Type() protoreflect.EnumType { + return &file_proto_v1_gateway_proto_enumTypes[1] +} + +func (x TunnelWsMessageType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TunnelWsMessageType.Descriptor instead. +func (TunnelWsMessageType) EnumDescriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{1} +} + type ChatEvent_ChatEventType int32 const ( @@ -148,11 +209,11 @@ func (x ChatEvent_ChatEventType) String() string { } func (ChatEvent_ChatEventType) Descriptor() protoreflect.EnumDescriptor { - return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() + return file_proto_v1_gateway_proto_enumTypes[2].Descriptor() } func (ChatEvent_ChatEventType) Type() protoreflect.EnumType { - return &file_proto_v1_gateway_proto_enumTypes[1] + return &file_proto_v1_gateway_proto_enumTypes[2] } func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { @@ -161,7 +222,7 @@ func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { // Deprecated: Use ChatEvent_ChatEventType.Descriptor instead. func (ChatEvent_ChatEventType) EnumDescriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43, 0} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51, 0} } type AuthRequest struct { @@ -327,11 +388,12 @@ type GatewayEnvelope struct { // *GatewayEnvelope_FsReadEditableText // *GatewayEnvelope_FsReadWorkspaceImage // *GatewayEnvelope_SftpRequest - // *GatewayEnvelope_TunnelControl - // *GatewayEnvelope_TunnelControlResp - // *GatewayEnvelope_TunnelFrame // *GatewayEnvelope_SettingsResetSshKnownHost // *GatewayEnvelope_ChatQueue + // *GatewayEnvelope_TunnelState + // *GatewayEnvelope_TunnelMutation + // *GatewayEnvelope_TunnelFrame + // *GatewayEnvelope_WorkspaceWatch Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -721,46 +783,55 @@ func (x *GatewayEnvelope) GetSftpRequest() *SftpRequest { return nil } -func (x *GatewayEnvelope) GetTunnelControl() *TunnelControlRequest { +func (x *GatewayEnvelope) GetSettingsResetSshKnownHost() *SettingsResetSshKnownHostRequest { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelControl); ok { - return x.TunnelControl + if x, ok := x.Payload.(*GatewayEnvelope_SettingsResetSshKnownHost); ok { + return x.SettingsResetSshKnownHost } } return nil } -func (x *GatewayEnvelope) GetTunnelControlResp() *TunnelControlResponse { +func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelControlResp); ok { - return x.TunnelControlResp + if x, ok := x.Payload.(*GatewayEnvelope_ChatQueue); ok { + return x.ChatQueue } } return nil } -func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { +func (x *GatewayEnvelope) GetTunnelState() *TunnelStateSnapshot { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { - return x.TunnelFrame + if x, ok := x.Payload.(*GatewayEnvelope_TunnelState); ok { + return x.TunnelState } } return nil } -func (x *GatewayEnvelope) GetSettingsResetSshKnownHost() *SettingsResetSshKnownHostRequest { +func (x *GatewayEnvelope) GetTunnelMutation() *TunnelMutation { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_SettingsResetSshKnownHost); ok { - return x.SettingsResetSshKnownHost + if x, ok := x.Payload.(*GatewayEnvelope_TunnelMutation); ok { + return x.TunnelMutation } } return nil } -func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { +func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_ChatQueue); ok { - return x.ChatQueue + if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + +func (x *GatewayEnvelope) GetWorkspaceWatch() *WorkspaceWatchRequest { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_WorkspaceWatch); ok { + return x.WorkspaceWatch } } return nil @@ -918,24 +989,28 @@ type GatewayEnvelope_SftpRequest struct { SftpRequest *SftpRequest `protobuf:"bytes,64,opt,name=sftp_request,json=sftpRequest,proto3,oneof"` } -type GatewayEnvelope_TunnelControl struct { - TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` +type GatewayEnvelope_SettingsResetSshKnownHost struct { + SettingsResetSshKnownHost *SettingsResetSshKnownHostRequest `protobuf:"bytes,72,opt,name=settings_reset_ssh_known_host,json=settingsResetSshKnownHost,proto3,oneof"` } -type GatewayEnvelope_TunnelControlResp struct { - TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` +type GatewayEnvelope_ChatQueue struct { + ChatQueue *ChatQueueRequest `protobuf:"bytes,73,opt,name=chat_queue,json=chatQueue,proto3,oneof"` } -type GatewayEnvelope_TunnelFrame struct { - TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +type GatewayEnvelope_TunnelState struct { + TunnelState *TunnelStateSnapshot `protobuf:"bytes,80,opt,name=tunnel_state,json=tunnelState,proto3,oneof"` } -type GatewayEnvelope_SettingsResetSshKnownHost struct { - SettingsResetSshKnownHost *SettingsResetSshKnownHostRequest `protobuf:"bytes,72,opt,name=settings_reset_ssh_known_host,json=settingsResetSshKnownHost,proto3,oneof"` +type GatewayEnvelope_TunnelMutation struct { + TunnelMutation *TunnelMutation `protobuf:"bytes,81,opt,name=tunnel_mutation,json=tunnelMutation,proto3,oneof"` } -type GatewayEnvelope_ChatQueue struct { - ChatQueue *ChatQueueRequest `protobuf:"bytes,73,opt,name=chat_queue,json=chatQueue,proto3,oneof"` +type GatewayEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,82,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + +type GatewayEnvelope_WorkspaceWatch struct { + WorkspaceWatch *WorkspaceWatchRequest `protobuf:"bytes,90,opt,name=workspace_watch,json=workspaceWatch,proto3,oneof"` } func (*GatewayEnvelope_ChatCommand) isGatewayEnvelope_Payload() {} @@ -1012,15 +1087,17 @@ func (*GatewayEnvelope_FsReadWorkspaceImage) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_SftpRequest) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_TunnelControl) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_SettingsResetSshKnownHost) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_TunnelControlResp) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_ChatQueue) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelState) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_SettingsResetSshKnownHost) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelMutation) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_ChatQueue) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_WorkspaceWatch) isGatewayEnvelope_Payload() {} type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1071,12 +1148,15 @@ type AgentEnvelope struct { // *AgentEnvelope_SftpEvent // *AgentEnvelope_ChatQueueResp // *AgentEnvelope_ChatQueueEvent - // *AgentEnvelope_TunnelControl - // *AgentEnvelope_TunnelControlResp - // *AgentEnvelope_TunnelFrame // *AgentEnvelope_ChatControl // *AgentEnvelope_RuntimeStatus // *AgentEnvelope_SettingsResetSshKnownHostResp + // *AgentEnvelope_ChatRuntimeSnapshot + // *AgentEnvelope_TunnelDesired + // *AgentEnvelope_TunnelMutationResult + // *AgentEnvelope_TunnelFrame + // *AgentEnvelope_TunnelProbeReport + // *AgentEnvelope_WorkspaceActivity // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1521,55 +1601,82 @@ func (x *AgentEnvelope) GetChatQueueEvent() *ChatQueueEvent { return nil } -func (x *AgentEnvelope) GetTunnelControl() *TunnelControlRequest { +func (x *AgentEnvelope) GetChatControl() *ChatControlEvent { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelControl); ok { - return x.TunnelControl + if x, ok := x.Payload.(*AgentEnvelope_ChatControl); ok { + return x.ChatControl } } return nil } -func (x *AgentEnvelope) GetTunnelControlResp() *TunnelControlResponse { +func (x *AgentEnvelope) GetRuntimeStatus() *RuntimeStatusEvent { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelControlResp); ok { - return x.TunnelControlResp + if x, ok := x.Payload.(*AgentEnvelope_RuntimeStatus); ok { + return x.RuntimeStatus } } return nil } -func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { +func (x *AgentEnvelope) GetSettingsResetSshKnownHostResp() *SettingsResetSshKnownHostResponse { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { - return x.TunnelFrame + if x, ok := x.Payload.(*AgentEnvelope_SettingsResetSshKnownHostResp); ok { + return x.SettingsResetSshKnownHostResp } } return nil } -func (x *AgentEnvelope) GetChatControl() *ChatControlEvent { +func (x *AgentEnvelope) GetChatRuntimeSnapshot() *ChatRuntimeSnapshot { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_ChatControl); ok { - return x.ChatControl + if x, ok := x.Payload.(*AgentEnvelope_ChatRuntimeSnapshot); ok { + return x.ChatRuntimeSnapshot } } return nil } -func (x *AgentEnvelope) GetRuntimeStatus() *RuntimeStatusEvent { +func (x *AgentEnvelope) GetTunnelDesired() *TunnelDesiredState { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_RuntimeStatus); ok { - return x.RuntimeStatus + if x, ok := x.Payload.(*AgentEnvelope_TunnelDesired); ok { + return x.TunnelDesired } } return nil } -func (x *AgentEnvelope) GetSettingsResetSshKnownHostResp() *SettingsResetSshKnownHostResponse { +func (x *AgentEnvelope) GetTunnelMutationResult() *TunnelMutationResult { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_SettingsResetSshKnownHostResp); ok { - return x.SettingsResetSshKnownHostResp + if x, ok := x.Payload.(*AgentEnvelope_TunnelMutationResult); ok { + return x.TunnelMutationResult + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelProbeReport() *TunnelProbeReport { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelProbeReport); ok { + return x.TunnelProbeReport + } + } + return nil +} + +func (x *AgentEnvelope) GetWorkspaceActivity() *WorkspaceActivityEvent { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_WorkspaceActivity); ok { + return x.WorkspaceActivity } } return nil @@ -1760,18 +1867,6 @@ type AgentEnvelope_ChatQueueEvent struct { ChatQueueEvent *ChatQueueEvent `protobuf:"bytes,76,opt,name=chat_queue_event,json=chatQueueEvent,proto3,oneof"` } -type AgentEnvelope_TunnelControl struct { - TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` -} - -type AgentEnvelope_TunnelControlResp struct { - TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` -} - -type AgentEnvelope_TunnelFrame struct { - TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` -} - type AgentEnvelope_ChatControl struct { ChatControl *ChatControlEvent `protobuf:"bytes,70,opt,name=chat_control,json=chatControl,proto3,oneof"` } @@ -1784,6 +1879,30 @@ type AgentEnvelope_SettingsResetSshKnownHostResp struct { SettingsResetSshKnownHostResp *SettingsResetSshKnownHostResponse `protobuf:"bytes,72,opt,name=settings_reset_ssh_known_host_resp,json=settingsResetSshKnownHostResp,proto3,oneof"` } +type AgentEnvelope_ChatRuntimeSnapshot struct { + ChatRuntimeSnapshot *ChatRuntimeSnapshot `protobuf:"bytes,77,opt,name=chat_runtime_snapshot,json=chatRuntimeSnapshot,proto3,oneof"` +} + +type AgentEnvelope_TunnelDesired struct { + TunnelDesired *TunnelDesiredState `protobuf:"bytes,80,opt,name=tunnel_desired,json=tunnelDesired,proto3,oneof"` +} + +type AgentEnvelope_TunnelMutationResult struct { + TunnelMutationResult *TunnelMutationResult `protobuf:"bytes,81,opt,name=tunnel_mutation_result,json=tunnelMutationResult,proto3,oneof"` +} + +type AgentEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,82,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + +type AgentEnvelope_TunnelProbeReport struct { + TunnelProbeReport *TunnelProbeReport `protobuf:"bytes,83,opt,name=tunnel_probe_report,json=tunnelProbeReport,proto3,oneof"` +} + +type AgentEnvelope_WorkspaceActivity struct { + WorkspaceActivity *WorkspaceActivityEvent `protobuf:"bytes,90,opt,name=workspace_activity,json=workspaceActivity,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1874,18 +1993,24 @@ func (*AgentEnvelope_ChatQueueResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_ChatQueueEvent) isAgentEnvelope_Payload() {} -func (*AgentEnvelope_TunnelControl) isAgentEnvelope_Payload() {} - -func (*AgentEnvelope_TunnelControlResp) isAgentEnvelope_Payload() {} - -func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} - func (*AgentEnvelope_ChatControl) isAgentEnvelope_Payload() {} func (*AgentEnvelope_RuntimeStatus) isAgentEnvelope_Payload() {} func (*AgentEnvelope_SettingsResetSshKnownHostResp) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_ChatRuntimeSnapshot) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelDesired) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelMutationResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelProbeReport) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_WorkspaceActivity) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -2352,36 +2477,32 @@ func (x *UploadedImagePreviewResponse) GetData() string { return "" } -type TunnelControlRequest struct { +type TunnelSpec struct { state protoimpl.MessageState `protogen:"open.v1"` - Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` - Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` - TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` - Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` - TtlSeconds uint32 `protobuf:"varint,6,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` - ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` - PublicUrl string `protobuf:"bytes,8,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` - PublicBaseUrl string `protobuf:"bytes,9,opt,name=public_base_url,json=publicBaseUrl,proto3" json:"public_base_url,omitempty"` - ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // agent-generated, stable across restarts + SlugHint string `protobuf:"bytes,2,opt,name=slug_hint,json=slugHint,proto3" json:"slug_hint,omitempty"` // last allocated slug; gateway honors when valid and unused + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` // http://localhost:PORT[/base] + ExpiresAt int64 `protobuf:"varint,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // unix seconds, 0 = never + ProjectPathKey string `protobuf:"bytes,6,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelControlRequest) Reset() { - *x = TunnelControlRequest{} +func (x *TunnelSpec) Reset() { + *x = TunnelSpec{} mi := &file_proto_v1_gateway_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelControlRequest) String() string { +func (x *TunnelSpec) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelControlRequest) ProtoMessage() {} +func (*TunnelSpec) ProtoMessage() {} -func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { +func (x *TunnelSpec) ProtoReflect() protoreflect.Message { mi := &file_proto_v1_gateway_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2393,107 +2514,131 @@ func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelControlRequest.ProtoReflect.Descriptor instead. -func (*TunnelControlRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use TunnelSpec.ProtoReflect.Descriptor instead. +func (*TunnelSpec) Descriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{12} } -func (x *TunnelControlRequest) GetAction() string { +func (x *TunnelSpec) GetId() string { if x != nil { - return x.Action + return x.Id } return "" } -func (x *TunnelControlRequest) GetTunnelId() string { +func (x *TunnelSpec) GetSlugHint() string { if x != nil { - return x.TunnelId + return x.SlugHint } return "" } -func (x *TunnelControlRequest) GetSlug() string { +func (x *TunnelSpec) GetName() string { if x != nil { - return x.Slug + return x.Name } return "" } -func (x *TunnelControlRequest) GetTargetUrl() string { +func (x *TunnelSpec) GetTargetUrl() string { if x != nil { return x.TargetUrl } return "" } -func (x *TunnelControlRequest) GetName() string { +func (x *TunnelSpec) GetExpiresAt() int64 { if x != nil { - return x.Name + return x.ExpiresAt } - return "" + return 0 } -func (x *TunnelControlRequest) GetTtlSeconds() uint32 { +func (x *TunnelSpec) GetProjectPathKey() string { if x != nil { - return x.TtlSeconds + return x.ProjectPathKey } - return 0 + return "" } -func (x *TunnelControlRequest) GetExpiresAt() int64 { - if x != nil { - return x.ExpiresAt - } - return 0 +type TunnelDesiredState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tunnels []*TunnelSpec `protobuf:"bytes,1,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Revision uint64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` // agent-side monotonic + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelDesiredState) Reset() { + *x = TunnelDesiredState{} + mi := &file_proto_v1_gateway_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelDesiredState) String() string { + return protoimpl.X.MessageStringOf(x) } -func (x *TunnelControlRequest) GetPublicUrl() string { +func (*TunnelDesiredState) ProtoMessage() {} + +func (x *TunnelDesiredState) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[13] if x != nil { - return x.PublicUrl + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return "" + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelDesiredState.ProtoReflect.Descriptor instead. +func (*TunnelDesiredState) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} } -func (x *TunnelControlRequest) GetPublicBaseUrl() string { +func (x *TunnelDesiredState) GetTunnels() []*TunnelSpec { if x != nil { - return x.PublicBaseUrl + return x.Tunnels } - return "" + return nil } -func (x *TunnelControlRequest) GetProjectPathKey() string { +func (x *TunnelDesiredState) GetRevision() uint64 { if x != nil { - return x.ProjectPathKey + return x.Revision } - return "" + return 0 } -type TunnelControlResponse struct { +type TunnelHealth struct { state protoimpl.MessageState `protogen:"open.v1"` - Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - Tunnels []*TunnelSummary `protobuf:"bytes,2,rep,name=tunnels,proto3" json:"tunnels,omitempty"` - Tunnel *TunnelSummary `protobuf:"bytes,3,opt,name=tunnel,proto3" json:"tunnel,omitempty"` - ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` - ErrorMessage string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` // "ok" | "failed" | "unknown" + HttpStatus uint32 `protobuf:"varint,2,opt,name=http_status,json=httpStatus,proto3" json:"http_status,omitempty"` // local layer only + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + CheckedAt int64 `protobuf:"varint,4,opt,name=checked_at,json=checkedAt,proto3" json:"checked_at,omitempty"` + RttMs uint32 `protobuf:"varint,5,opt,name=rtt_ms,json=rttMs,proto3" json:"rtt_ms,omitempty"` // relay layer only unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelControlResponse) Reset() { - *x = TunnelControlResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[13] +func (x *TunnelHealth) Reset() { + *x = TunnelHealth{} + mi := &file_proto_v1_gateway_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelControlResponse) String() string { +func (x *TunnelHealth) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelControlResponse) ProtoMessage() {} +func (*TunnelHealth) ProtoMessage() {} -func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[13] +func (x *TunnelHealth) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2504,77 +2649,77 @@ func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelControlResponse.ProtoReflect.Descriptor instead. -func (*TunnelControlResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} +// Deprecated: Use TunnelHealth.ProtoReflect.Descriptor instead. +func (*TunnelHealth) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} } -func (x *TunnelControlResponse) GetAction() string { +func (x *TunnelHealth) GetStatus() string { if x != nil { - return x.Action + return x.Status } return "" } -func (x *TunnelControlResponse) GetTunnels() []*TunnelSummary { +func (x *TunnelHealth) GetHttpStatus() uint32 { if x != nil { - return x.Tunnels + return x.HttpStatus } - return nil + return 0 } -func (x *TunnelControlResponse) GetTunnel() *TunnelSummary { +func (x *TunnelHealth) GetError() string { if x != nil { - return x.Tunnel + return x.Error } - return nil + return "" } -func (x *TunnelControlResponse) GetErrorCode() string { +func (x *TunnelHealth) GetCheckedAt() int64 { if x != nil { - return x.ErrorCode + return x.CheckedAt } - return "" + return 0 } -func (x *TunnelControlResponse) GetErrorMessage() string { +func (x *TunnelHealth) GetRttMs() uint32 { if x != nil { - return x.ErrorMessage + return x.RttMs } - return "" + return 0 } -type TunnelSummary struct { +type TunnelStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` - PublicUrl string `protobuf:"bytes,5,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + PublicPath string `protobuf:"bytes,5,opt,name=public_path,json=publicPath,proto3" json:"public_path,omitempty"` // "/t/{slug}/"; clients compose the full URL CreatedAt int64 `protobuf:"varint,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` ActiveConnections uint32 `protobuf:"varint,8,opt,name=active_connections,json=activeConnections,proto3" json:"active_connections,omitempty"` - Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` - ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + ProjectPathKey string `protobuf:"bytes,9,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + Local *TunnelHealth `protobuf:"bytes,10,opt,name=local,proto3" json:"local,omitempty"` // agent -> local service reachability unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelSummary) Reset() { - *x = TunnelSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[14] +func (x *TunnelStatus) Reset() { + *x = TunnelStatus{} + mi := &file_proto_v1_gateway_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelSummary) String() string { +func (x *TunnelStatus) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelSummary) ProtoMessage() {} +func (*TunnelStatus) ProtoMessage() {} -func (x *TunnelSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[14] +func (x *TunnelStatus) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2585,104 +2730,106 @@ func (x *TunnelSummary) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelSummary.ProtoReflect.Descriptor instead. -func (*TunnelSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} +// Deprecated: Use TunnelStatus.ProtoReflect.Descriptor instead. +func (*TunnelStatus) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} } -func (x *TunnelSummary) GetId() string { +func (x *TunnelStatus) GetId() string { if x != nil { return x.Id } return "" } -func (x *TunnelSummary) GetSlug() string { +func (x *TunnelStatus) GetSlug() string { if x != nil { return x.Slug } return "" } -func (x *TunnelSummary) GetName() string { +func (x *TunnelStatus) GetName() string { if x != nil { return x.Name } return "" } -func (x *TunnelSummary) GetTargetUrl() string { +func (x *TunnelStatus) GetTargetUrl() string { if x != nil { return x.TargetUrl } return "" } -func (x *TunnelSummary) GetPublicUrl() string { +func (x *TunnelStatus) GetPublicPath() string { if x != nil { - return x.PublicUrl + return x.PublicPath } return "" } -func (x *TunnelSummary) GetCreatedAt() int64 { +func (x *TunnelStatus) GetCreatedAt() int64 { if x != nil { return x.CreatedAt } return 0 } -func (x *TunnelSummary) GetExpiresAt() int64 { +func (x *TunnelStatus) GetExpiresAt() int64 { if x != nil { return x.ExpiresAt } return 0 } -func (x *TunnelSummary) GetActiveConnections() uint32 { +func (x *TunnelStatus) GetActiveConnections() uint32 { if x != nil { return x.ActiveConnections } return 0 } -func (x *TunnelSummary) GetStatus() string { +func (x *TunnelStatus) GetProjectPathKey() string { if x != nil { - return x.Status + return x.ProjectPathKey } return "" } -func (x *TunnelSummary) GetProjectPathKey() string { +func (x *TunnelStatus) GetLocal() *TunnelHealth { if x != nil { - return x.ProjectPathKey + return x.Local } - return "" + return nil } -type TunnelHeader struct { +type TunnelStateSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Tunnels []*TunnelStatus `protobuf:"bytes,1,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Revision uint64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` // gateway-side monotonic + AgentOnline bool `protobuf:"varint,3,opt,name=agent_online,json=agentOnline,proto3" json:"agent_online,omitempty"` + Relay *TunnelHealth `protobuf:"bytes,4,opt,name=relay,proto3" json:"relay,omitempty"` // gateway <-> agent frame path unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelHeader) Reset() { - *x = TunnelHeader{} - mi := &file_proto_v1_gateway_proto_msgTypes[15] +func (x *TunnelStateSnapshot) Reset() { + *x = TunnelStateSnapshot{} + mi := &file_proto_v1_gateway_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelHeader) String() string { +func (x *TunnelStateSnapshot) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelHeader) ProtoMessage() {} +func (*TunnelStateSnapshot) ProtoMessage() {} -func (x *TunnelHeader) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[15] +func (x *TunnelStateSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2693,46 +2840,353 @@ func (x *TunnelHeader) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelHeader.ProtoReflect.Descriptor instead. -func (*TunnelHeader) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} +// Deprecated: Use TunnelStateSnapshot.ProtoReflect.Descriptor instead. +func (*TunnelStateSnapshot) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} } -func (x *TunnelHeader) GetName() string { +func (x *TunnelStateSnapshot) GetTunnels() []*TunnelStatus { if x != nil { - return x.Name + return x.Tunnels } - return "" + return nil } -func (x *TunnelHeader) GetValue() string { +func (x *TunnelStateSnapshot) GetRevision() uint64 { if x != nil { - return x.Value + return x.Revision } - return "" + return 0 } -type TunnelFrame struct { +func (x *TunnelStateSnapshot) GetAgentOnline() bool { + if x != nil { + return x.AgentOnline + } + return false +} + +func (x *TunnelStateSnapshot) GetRelay() *TunnelHealth { + if x != nil { + return x.Relay + } + return nil +} + +type TunnelMutation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` // "create" | "update" | "close" | "check" + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` // update/close/check + TargetUrl string `protobuf:"bytes,3,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + TtlSeconds *uint32 `protobuf:"varint,5,opt,name=ttl_seconds,json=ttlSeconds,proto3,oneof" json:"ttl_seconds,omitempty"` // absent on update = keep current expiry + ProjectPathKey string `protobuf:"bytes,6,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelMutation) Reset() { + *x = TunnelMutation{} + mi := &file_proto_v1_gateway_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelMutation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelMutation) ProtoMessage() {} + +func (x *TunnelMutation) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelMutation.ProtoReflect.Descriptor instead. +func (*TunnelMutation) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} +} + +func (x *TunnelMutation) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *TunnelMutation) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelMutation) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *TunnelMutation) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelMutation) GetTtlSeconds() uint32 { + if x != nil && x.TtlSeconds != nil { + return *x.TtlSeconds + } + return 0 +} + +func (x *TunnelMutation) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +type TunnelMutationResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TunnelId string `protobuf:"bytes,1,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + ErrorCode string `protobuf:"bytes,2,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` // "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + ErrorMessage string `protobuf:"bytes,3,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelMutationResult) Reset() { + *x = TunnelMutationResult{} + mi := &file_proto_v1_gateway_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelMutationResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelMutationResult) ProtoMessage() {} + +func (x *TunnelMutationResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelMutationResult.ProtoReflect.Descriptor instead. +func (*TunnelMutationResult) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} +} + +func (x *TunnelMutationResult) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelMutationResult) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *TunnelMutationResult) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type TunnelProbeResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TunnelId string `protobuf:"bytes,1,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Local *TunnelHealth `protobuf:"bytes,2,opt,name=local,proto3" json:"local,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelProbeResult) Reset() { + *x = TunnelProbeResult{} + mi := &file_proto_v1_gateway_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelProbeResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelProbeResult) ProtoMessage() {} + +func (x *TunnelProbeResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelProbeResult.ProtoReflect.Descriptor instead. +func (*TunnelProbeResult) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} +} + +func (x *TunnelProbeResult) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelProbeResult) GetLocal() *TunnelHealth { + if x != nil { + return x.Local + } + return nil +} + +type TunnelProbeReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*TunnelProbeResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelProbeReport) Reset() { + *x = TunnelProbeReport{} + mi := &file_proto_v1_gateway_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelProbeReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelProbeReport) ProtoMessage() {} + +func (x *TunnelProbeReport) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelProbeReport.ProtoReflect.Descriptor instead. +func (*TunnelProbeReport) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} +} + +func (x *TunnelProbeReport) GetResults() []*TunnelProbeResult { + if x != nil { + return x.Results + } + return nil +} + +type TunnelHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelHeader) Reset() { + *x = TunnelHeader{} + mi := &file_proto_v1_gateway_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelHeader) ProtoMessage() {} + +func (x *TunnelHeader) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelHeader.ProtoReflect.Descriptor instead. +func (*TunnelHeader) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} +} + +func (x *TunnelHeader) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelHeader) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type TunnelFrame struct { state protoimpl.MessageState `protogen:"open.v1"` StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` - TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` - Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` - Kind TunnelFrameKind `protobuf:"varint,4,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` - Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` - Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` - Headers []*TunnelHeader `protobuf:"bytes,7,rep,name=headers,proto3" json:"headers,omitempty"` - StatusCode uint32 `protobuf:"varint,8,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` - Body []byte `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` - EndStream bool `protobuf:"varint,10,opt,name=end_stream,json=endStream,proto3" json:"end_stream,omitempty"` - Error string `protobuf:"bytes,11,opt,name=error,proto3" json:"error,omitempty"` - WsMessageType string `protobuf:"bytes,12,opt,name=ws_message_type,json=wsMessageType,proto3" json:"ws_message_type,omitempty"` + Kind TunnelFrameKind `protobuf:"varint,2,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` + TargetUrl string `protobuf:"bytes,3,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` // set on HTTP_REQUEST_START and WS_DIAL only + Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,5,opt,name=path,proto3" json:"path,omitempty"` // path+query relative to the target base + Headers []*TunnelHeader `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty"` + Status uint32 `protobuf:"varint,7,opt,name=status,proto3" json:"status,omitempty"` + Body []byte `protobuf:"bytes,8,opt,name=body,proto3" json:"body,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + WsMessageType TunnelWsMessageType `protobuf:"varint,10,opt,name=ws_message_type,json=wsMessageType,proto3,enum=liveagent.gateway.v1.TunnelWsMessageType" json:"ws_message_type,omitempty"` + WsSubprotocol string `protobuf:"bytes,11,opt,name=ws_subprotocol,json=wsSubprotocol,proto3" json:"ws_subprotocol,omitempty"` + WsCloseCode uint32 `protobuf:"varint,12,opt,name=ws_close_code,json=wsCloseCode,proto3" json:"ws_close_code,omitempty"` + WsCloseReason string `protobuf:"bytes,13,opt,name=ws_close_reason,json=wsCloseReason,proto3" json:"ws_close_reason,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TunnelFrame) Reset() { *x = TunnelFrame{} - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2744,7 +3198,7 @@ func (x *TunnelFrame) String() string { func (*TunnelFrame) ProtoMessage() {} func (x *TunnelFrame) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2757,7 +3211,7 @@ func (x *TunnelFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TunnelFrame.ProtoReflect.Descriptor instead. func (*TunnelFrame) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} } func (x *TunnelFrame) GetStreamId() string { @@ -2767,27 +3221,20 @@ func (x *TunnelFrame) GetStreamId() string { return "" } -func (x *TunnelFrame) GetTunnelId() string { +func (x *TunnelFrame) GetKind() TunnelFrameKind { if x != nil { - return x.TunnelId + return x.Kind } - return "" + return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED } -func (x *TunnelFrame) GetSlug() string { +func (x *TunnelFrame) GetTargetUrl() string { if x != nil { - return x.Slug + return x.TargetUrl } return "" } -func (x *TunnelFrame) GetKind() TunnelFrameKind { - if x != nil { - return x.Kind - } - return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED -} - func (x *TunnelFrame) GetMethod() string { if x != nil { return x.Method @@ -2809,9 +3256,9 @@ func (x *TunnelFrame) GetHeaders() []*TunnelHeader { return nil } -func (x *TunnelFrame) GetStatusCode() uint32 { +func (x *TunnelFrame) GetStatus() uint32 { if x != nil { - return x.StatusCode + return x.Status } return 0 } @@ -2823,27 +3270,169 @@ func (x *TunnelFrame) GetBody() []byte { return nil } -func (x *TunnelFrame) GetEndStream() bool { +func (x *TunnelFrame) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *TunnelFrame) GetWsMessageType() TunnelWsMessageType { if x != nil { - return x.EndStream + return x.WsMessageType } - return false + return TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED } -func (x *TunnelFrame) GetError() string { +func (x *TunnelFrame) GetWsSubprotocol() string { if x != nil { - return x.Error + return x.WsSubprotocol } return "" } -func (x *TunnelFrame) GetWsMessageType() string { +func (x *TunnelFrame) GetWsCloseCode() uint32 { if x != nil { - return x.WsMessageType + return x.WsCloseCode + } + return 0 +} + +func (x *TunnelFrame) GetWsCloseReason() string { + if x != nil { + return x.WsCloseReason } return "" } +type WorkspaceWatchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workdirs []string `protobuf:"bytes,1,rep,name=workdirs,proto3" json:"workdirs,omitempty"` // full desired set; replaces the previous one + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceWatchRequest) Reset() { + *x = WorkspaceWatchRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceWatchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceWatchRequest) ProtoMessage() {} + +func (x *WorkspaceWatchRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceWatchRequest.ProtoReflect.Descriptor instead. +func (*WorkspaceWatchRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} +} + +func (x *WorkspaceWatchRequest) GetWorkdirs() []string { + if x != nil { + return x.Workdirs + } + return nil +} + +type WorkspaceActivityEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` + Revision uint64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` // per-workdir monotonic within one agent process + Fs bool `protobuf:"varint,3,opt,name=fs,proto3" json:"fs,omitempty"` // working-tree content changed + Git bool `protobuf:"varint,4,opt,name=git,proto3" json:"git,omitempty"` // git state (HEAD/refs/index/...) changed + ChangedPaths []string `protobuf:"bytes,5,rep,name=changed_paths,json=changedPaths,proto3" json:"changed_paths,omitempty"` // relative to workdir, deduped, capped + Truncated bool `protobuf:"varint,6,opt,name=truncated,proto3" json:"truncated,omitempty"` // changed_paths hit the cap or is unknown + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceActivityEvent) Reset() { + *x = WorkspaceActivityEvent{} + mi := &file_proto_v1_gateway_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceActivityEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceActivityEvent) ProtoMessage() {} + +func (x *WorkspaceActivityEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkspaceActivityEvent.ProtoReflect.Descriptor instead. +func (*WorkspaceActivityEvent) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} +} + +func (x *WorkspaceActivityEvent) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *WorkspaceActivityEvent) GetRevision() uint64 { + if x != nil { + return x.Revision + } + return 0 +} + +func (x *WorkspaceActivityEvent) GetFs() bool { + if x != nil { + return x.Fs + } + return false +} + +func (x *WorkspaceActivityEvent) GetGit() bool { + if x != nil { + return x.Git + } + return false +} + +func (x *WorkspaceActivityEvent) GetChangedPaths() []string { + if x != nil { + return x.ChangedPaths + } + return nil +} + +func (x *WorkspaceActivityEvent) GetTruncated() bool { + if x != nil { + return x.Truncated + } + return false +} + type MemoryManageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` @@ -2854,7 +3443,7 @@ type MemoryManageRequest struct { func (x *MemoryManageRequest) Reset() { *x = MemoryManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2866,7 +3455,7 @@ func (x *MemoryManageRequest) String() string { func (*MemoryManageRequest) ProtoMessage() {} func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2879,7 +3468,7 @@ func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageRequest.ProtoReflect.Descriptor instead. func (*MemoryManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} } func (x *MemoryManageRequest) GetCommand() string { @@ -2905,7 +3494,7 @@ type MemoryManageResponse struct { func (x *MemoryManageResponse) Reset() { *x = MemoryManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2917,7 +3506,7 @@ func (x *MemoryManageResponse) String() string { func (*MemoryManageResponse) ProtoMessage() {} func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2930,7 +3519,7 @@ func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageResponse.ProtoReflect.Descriptor instead. func (*MemoryManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} } func (x *MemoryManageResponse) GetResultJson() string { @@ -2965,7 +3554,7 @@ type TerminalRequest struct { func (x *TerminalRequest) Reset() { *x = TerminalRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2977,7 +3566,7 @@ func (x *TerminalRequest) String() string { func (*TerminalRequest) ProtoMessage() {} func (x *TerminalRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2990,7 +3579,7 @@ func (x *TerminalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalRequest.ProtoReflect.Descriptor instead. func (*TerminalRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} } func (x *TerminalRequest) GetAction() string { @@ -3135,7 +3724,7 @@ type TerminalSession struct { func (x *TerminalSession) Reset() { *x = TerminalSession{} - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3147,7 +3736,7 @@ func (x *TerminalSession) String() string { func (*TerminalSession) ProtoMessage() {} func (x *TerminalSession) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3160,7 +3749,7 @@ func (x *TerminalSession) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSession.ProtoReflect.Descriptor instead. func (*TerminalSession) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} } func (x *TerminalSession) GetId() string { @@ -3286,7 +3875,7 @@ type TerminalSshMetadata struct { func (x *TerminalSshMetadata) Reset() { *x = TerminalSshMetadata{} - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3298,7 +3887,7 @@ func (x *TerminalSshMetadata) String() string { func (*TerminalSshMetadata) ProtoMessage() {} func (x *TerminalSshMetadata) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3311,7 +3900,7 @@ func (x *TerminalSshMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshMetadata.ProtoReflect.Descriptor instead. func (*TerminalSshMetadata) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} } func (x *TerminalSshMetadata) GetHostId() string { @@ -3404,7 +3993,7 @@ type SftpRequest struct { func (x *SftpRequest) Reset() { *x = SftpRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3416,7 +4005,7 @@ func (x *SftpRequest) String() string { func (*SftpRequest) ProtoMessage() {} func (x *SftpRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3429,7 +4018,7 @@ func (x *SftpRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpRequest.ProtoReflect.Descriptor instead. func (*SftpRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} } func (x *SftpRequest) GetAction() string { @@ -3529,7 +4118,7 @@ type SftpEntry struct { func (x *SftpEntry) Reset() { *x = SftpEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3541,7 +4130,7 @@ func (x *SftpEntry) String() string { func (*SftpEntry) ProtoMessage() {} func (x *SftpEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3554,7 +4143,7 @@ func (x *SftpEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpEntry.ProtoReflect.Descriptor instead. func (*SftpEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} } func (x *SftpEntry) GetPath() string { @@ -3612,7 +4201,7 @@ type SftpTransfer struct { func (x *SftpTransfer) Reset() { *x = SftpTransfer{} - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3624,7 +4213,7 @@ func (x *SftpTransfer) String() string { func (*SftpTransfer) ProtoMessage() {} func (x *SftpTransfer) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3637,7 +4226,7 @@ func (x *SftpTransfer) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpTransfer.ProtoReflect.Descriptor instead. func (*SftpTransfer) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} } func (x *SftpTransfer) GetId() string { @@ -3738,7 +4327,7 @@ type SftpResponse struct { func (x *SftpResponse) Reset() { *x = SftpResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3750,7 +4339,7 @@ func (x *SftpResponse) String() string { func (*SftpResponse) ProtoMessage() {} func (x *SftpResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3763,7 +4352,7 @@ func (x *SftpResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpResponse.ProtoReflect.Descriptor instead. func (*SftpResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} } func (x *SftpResponse) GetAction() string { @@ -3818,7 +4407,7 @@ type SftpEvent struct { func (x *SftpEvent) Reset() { *x = SftpEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3830,7 +4419,7 @@ func (x *SftpEvent) String() string { func (*SftpEvent) ProtoMessage() {} func (x *SftpEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3843,7 +4432,7 @@ func (x *SftpEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpEvent.ProtoReflect.Descriptor instead. func (*SftpEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} } func (x *SftpEvent) GetKind() string { @@ -3878,7 +4467,7 @@ type TerminalSshPrompt struct { func (x *TerminalSshPrompt) Reset() { *x = TerminalSshPrompt{} - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3890,7 +4479,7 @@ func (x *TerminalSshPrompt) String() string { func (*TerminalSshPrompt) ProtoMessage() {} func (x *TerminalSshPrompt) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3903,7 +4492,7 @@ func (x *TerminalSshPrompt) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshPrompt.ProtoReflect.Descriptor instead. func (*TerminalSshPrompt) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} } func (x *TerminalSshPrompt) GetId() string { @@ -3987,7 +4576,7 @@ type TerminalShellOption struct { func (x *TerminalShellOption) Reset() { *x = TerminalShellOption{} - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3999,7 +4588,7 @@ func (x *TerminalShellOption) String() string { func (*TerminalShellOption) ProtoMessage() {} func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4012,7 +4601,7 @@ func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalShellOption.ProtoReflect.Descriptor instead. func (*TerminalShellOption) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} } func (x *TerminalShellOption) GetId() string { @@ -4050,7 +4639,7 @@ type TerminalSshTab struct { func (x *TerminalSshTab) Reset() { *x = TerminalSshTab{} - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4062,7 +4651,7 @@ func (x *TerminalSshTab) String() string { func (*TerminalSshTab) ProtoMessage() {} func (x *TerminalSshTab) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4075,7 +4664,7 @@ func (x *TerminalSshTab) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshTab.ProtoReflect.Descriptor instead. func (*TerminalSshTab) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} } func (x *TerminalSshTab) GetId() string { @@ -4131,7 +4720,7 @@ type TerminalSshTabsSnapshot struct { func (x *TerminalSshTabsSnapshot) Reset() { *x = TerminalSshTabsSnapshot{} - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4143,7 +4732,7 @@ func (x *TerminalSshTabsSnapshot) String() string { func (*TerminalSshTabsSnapshot) ProtoMessage() {} func (x *TerminalSshTabsSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4156,7 +4745,7 @@ func (x *TerminalSshTabsSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshTabsSnapshot.ProtoReflect.Descriptor instead. func (*TerminalSshTabsSnapshot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} } func (x *TerminalSshTabsSnapshot) GetProjectPathKey() string { @@ -4200,7 +4789,7 @@ type TerminalResponse struct { func (x *TerminalResponse) Reset() { *x = TerminalResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4212,7 +4801,7 @@ func (x *TerminalResponse) String() string { func (*TerminalResponse) ProtoMessage() {} func (x *TerminalResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4225,7 +4814,7 @@ func (x *TerminalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalResponse.ProtoReflect.Descriptor instead. func (*TerminalResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} } func (x *TerminalResponse) GetAction() string { @@ -4328,7 +4917,7 @@ type TerminalEvent struct { func (x *TerminalEvent) Reset() { *x = TerminalEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4340,7 +4929,7 @@ func (x *TerminalEvent) String() string { func (*TerminalEvent) ProtoMessage() {} func (x *TerminalEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4353,7 +4942,7 @@ func (x *TerminalEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalEvent.ProtoReflect.Descriptor instead. func (*TerminalEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} } func (x *TerminalEvent) GetKind() string { @@ -4434,7 +5023,7 @@ type TerminalStreamFrame struct { func (x *TerminalStreamFrame) Reset() { *x = TerminalStreamFrame{} - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4446,7 +5035,7 @@ func (x *TerminalStreamFrame) String() string { func (*TerminalStreamFrame) ProtoMessage() {} func (x *TerminalStreamFrame) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4459,7 +5048,7 @@ func (x *TerminalStreamFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalStreamFrame.ProtoReflect.Descriptor instead. func (*TerminalStreamFrame) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} } func (x *TerminalStreamFrame) GetKind() string { @@ -4571,7 +5160,7 @@ type GitRequest struct { func (x *GitRequest) Reset() { *x = GitRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4583,7 +5172,7 @@ func (x *GitRequest) String() string { func (*GitRequest) ProtoMessage() {} func (x *GitRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4596,7 +5185,7 @@ func (x *GitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GitRequest.ProtoReflect.Descriptor instead. func (*GitRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} } func (x *GitRequest) GetAction() string { @@ -4630,7 +5219,7 @@ type GitResponse struct { func (x *GitResponse) Reset() { *x = GitResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4642,7 +5231,7 @@ func (x *GitResponse) String() string { func (*GitResponse) ProtoMessage() {} func (x *GitResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4655,7 +5244,7 @@ func (x *GitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GitResponse.ProtoReflect.Descriptor instead. func (*GitResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} } func (x *GitResponse) GetAction() string { @@ -4690,7 +5279,7 @@ type ChatRequest struct { func (x *ChatRequest) Reset() { *x = ChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4702,7 +5291,7 @@ func (x *ChatRequest) String() string { func (*ChatRequest) ProtoMessage() {} func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4715,7 +5304,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} } func (x *ChatRequest) GetConversationId() string { @@ -4802,7 +5391,7 @@ type ChatMessageRef struct { func (x *ChatMessageRef) Reset() { *x = ChatMessageRef{} - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4814,7 +5403,7 @@ func (x *ChatMessageRef) String() string { func (*ChatMessageRef) ProtoMessage() {} func (x *ChatMessageRef) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4827,7 +5416,7 @@ func (x *ChatMessageRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatMessageRef.ProtoReflect.Descriptor instead. func (*ChatMessageRef) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} } func (x *ChatMessageRef) GetSegmentIndex() int32 { @@ -4881,7 +5470,7 @@ type CancelChatRequest struct { func (x *CancelChatRequest) Reset() { *x = CancelChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4893,7 +5482,7 @@ func (x *CancelChatRequest) String() string { func (*CancelChatRequest) ProtoMessage() {} func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4906,7 +5495,7 @@ func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelChatRequest.ProtoReflect.Descriptor instead. func (*CancelChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} } func (x *CancelChatRequest) GetConversationId() string { @@ -4928,7 +5517,7 @@ type ChatCommandRequest struct { func (x *ChatCommandRequest) Reset() { *x = ChatCommandRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4940,7 +5529,7 @@ func (x *ChatCommandRequest) String() string { func (*ChatCommandRequest) ProtoMessage() {} func (x *ChatCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4953,7 +5542,7 @@ func (x *ChatCommandRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatCommandRequest.ProtoReflect.Descriptor instead. func (*ChatCommandRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} } func (x *ChatCommandRequest) GetType() string { @@ -5000,7 +5589,7 @@ type ChatQueueRequest struct { func (x *ChatQueueRequest) Reset() { *x = ChatQueueRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5012,7 +5601,7 @@ func (x *ChatQueueRequest) String() string { func (*ChatQueueRequest) ProtoMessage() {} func (x *ChatQueueRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5025,7 +5614,7 @@ func (x *ChatQueueRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueRequest.ProtoReflect.Descriptor instead. func (*ChatQueueRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} } func (x *ChatQueueRequest) GetAction() string { @@ -5098,7 +5687,7 @@ type ChatQueueResponse struct { func (x *ChatQueueResponse) Reset() { *x = ChatQueueResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5110,7 +5699,7 @@ func (x *ChatQueueResponse) String() string { func (*ChatQueueResponse) ProtoMessage() {} func (x *ChatQueueResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5123,7 +5712,7 @@ func (x *ChatQueueResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueResponse.ProtoReflect.Descriptor instead. func (*ChatQueueResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} } func (x *ChatQueueResponse) GetAccepted() bool { @@ -5179,7 +5768,7 @@ type ChatQueueEvent struct { func (x *ChatQueueEvent) Reset() { *x = ChatQueueEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5191,7 +5780,7 @@ func (x *ChatQueueEvent) String() string { func (*ChatQueueEvent) ProtoMessage() {} func (x *ChatQueueEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5204,7 +5793,7 @@ func (x *ChatQueueEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueEvent.ProtoReflect.Descriptor instead. func (*ChatQueueEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} } func (x *ChatQueueEvent) GetConversationId() string { @@ -5239,7 +5828,7 @@ type ChatEvent struct { func (x *ChatEvent) Reset() { *x = ChatEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5251,7 +5840,7 @@ func (x *ChatEvent) String() string { func (*ChatEvent) ProtoMessage() {} func (x *ChatEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5264,7 +5853,7 @@ func (x *ChatEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEvent.ProtoReflect.Descriptor instead. func (*ChatEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} } func (x *ChatEvent) GetType() ChatEvent_ChatEventType { @@ -5305,7 +5894,7 @@ type ChatControlEvent struct { func (x *ChatControlEvent) Reset() { *x = ChatControlEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5317,7 +5906,7 @@ func (x *ChatControlEvent) String() string { func (*ChatControlEvent) ProtoMessage() {} func (x *ChatControlEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5330,7 +5919,7 @@ func (x *ChatControlEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatControlEvent.ProtoReflect.Descriptor instead. func (*ChatControlEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} } func (x *ChatControlEvent) GetRequestId() string { @@ -5365,35 +5954,159 @@ func (x *ChatControlEvent) GetType() string { if x != nil { return x.Type } - return "" + return "" +} + +func (x *ChatControlEvent) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ChatControlEvent) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *ChatControlEvent) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ChatControlEvent) GetSeq() int64 { + if x != nil { + return x.Seq + } + return 0 +} + +type ChatRuntimeSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ClientRequestId string `protobuf:"bytes,3,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` + WorkerId string `protobuf:"bytes,4,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"` + State string `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"` + Cwd string `protobuf:"bytes,6,opt,name=cwd,proto3" json:"cwd,omitempty"` + UpdatedAt int64 `protobuf:"varint,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + Revision int64 `protobuf:"varint,8,opt,name=revision,proto3" json:"revision,omitempty"` + EntriesJson string `protobuf:"bytes,9,opt,name=entries_json,json=entriesJson,proto3" json:"entries_json,omitempty"` + ToolStatus string `protobuf:"bytes,10,opt,name=tool_status,json=toolStatus,proto3" json:"tool_status,omitempty"` + ToolStatusIsCompaction bool `protobuf:"varint,11,opt,name=tool_status_is_compaction,json=toolStatusIsCompaction,proto3" json:"tool_status_is_compaction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatRuntimeSnapshot) Reset() { + *x = ChatRuntimeSnapshot{} + mi := &file_proto_v1_gateway_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatRuntimeSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatRuntimeSnapshot) ProtoMessage() {} + +func (x *ChatRuntimeSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatRuntimeSnapshot.ProtoReflect.Descriptor instead. +func (*ChatRuntimeSnapshot) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} +} + +func (x *ChatRuntimeSnapshot) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetWorkerId() string { + if x != nil { + return x.WorkerId + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *ChatRuntimeSnapshot) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 } -func (x *ChatControlEvent) GetState() string { +func (x *ChatRuntimeSnapshot) GetRevision() int64 { if x != nil { - return x.State + return x.Revision } - return "" + return 0 } -func (x *ChatControlEvent) GetErrorCode() string { +func (x *ChatRuntimeSnapshot) GetEntriesJson() string { if x != nil { - return x.ErrorCode + return x.EntriesJson } return "" } -func (x *ChatControlEvent) GetMessage() string { +func (x *ChatRuntimeSnapshot) GetToolStatus() string { if x != nil { - return x.Message + return x.ToolStatus } return "" } -func (x *ChatControlEvent) GetSeq() int64 { +func (x *ChatRuntimeSnapshot) GetToolStatusIsCompaction() bool { if x != nil { - return x.Seq + return x.ToolStatusIsCompaction } - return 0 + return false } type RuntimeStatusEvent struct { @@ -5403,13 +6116,15 @@ type RuntimeStatusEvent struct { Visible bool `protobuf:"varint,3,opt,name=visible,proto3" json:"visible,omitempty"` ActiveRunCount uint32 `protobuf:"varint,4,opt,name=active_run_count,json=activeRunCount,proto3" json:"active_run_count,omitempty"` Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + ActiveRuns []*ChatRunReport `protobuf:"bytes,6,rep,name=active_runs,json=activeRuns,proto3" json:"active_runs,omitempty"` + FinishedRuns []*ChatRunReport `protobuf:"bytes,7,rep,name=finished_runs,json=finishedRuns,proto3" json:"finished_runs,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RuntimeStatusEvent) Reset() { *x = RuntimeStatusEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5421,7 +6136,7 @@ func (x *RuntimeStatusEvent) String() string { func (*RuntimeStatusEvent) ProtoMessage() {} func (x *RuntimeStatusEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5434,7 +6149,7 @@ func (x *RuntimeStatusEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use RuntimeStatusEvent.ProtoReflect.Descriptor instead. func (*RuntimeStatusEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *RuntimeStatusEvent) GetWorkerId() string { @@ -5472,6 +6187,104 @@ func (x *RuntimeStatusEvent) GetTimestamp() int64 { return 0 } +func (x *RuntimeStatusEvent) GetActiveRuns() []*ChatRunReport { + if x != nil { + return x.ActiveRuns + } + return nil +} + +func (x *RuntimeStatusEvent) GetFinishedRuns() []*ChatRunReport { + if x != nil { + return x.FinishedRuns + } + return nil +} + +type ChatRunReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + UpdatedAt int64 `protobuf:"varint,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatRunReport) Reset() { + *x = ChatRunReport{} + mi := &file_proto_v1_gateway_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatRunReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatRunReport) ProtoMessage() {} + +func (x *ChatRunReport) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatRunReport.ProtoReflect.Descriptor instead. +func (*ChatRunReport) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} +} + +func (x *ChatRunReport) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatRunReport) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatRunReport) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ChatRunReport) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *ChatRunReport) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ChatRunReport) GetUpdatedAt() int64 { + if x != nil { + return x.UpdatedAt + } + return 0 +} + type CronManageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` @@ -5483,7 +6296,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5495,7 +6308,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5508,7 +6321,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *CronManageRequest) GetAction() string { @@ -5542,7 +6355,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5554,7 +6367,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5567,7 +6380,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } func (x *CronManageResponse) GetAction() string { @@ -5596,7 +6409,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5608,7 +6421,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5621,7 +6434,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *HistoryListRequest) GetPage() int32 { @@ -5662,7 +6475,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5674,7 +6487,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5687,7 +6500,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -5724,7 +6537,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5736,7 +6549,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5749,7 +6562,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *ConversationSummary) GetId() string { @@ -5846,7 +6659,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5858,7 +6671,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5871,7 +6684,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *HistoryGetRequest) GetConversationId() string { @@ -5902,7 +6715,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5914,7 +6727,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5927,7 +6740,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } func (x *HistoryGetResponse) GetConversationId() string { @@ -5983,7 +6796,7 @@ type HistoryPrefixRequest struct { func (x *HistoryPrefixRequest) Reset() { *x = HistoryPrefixRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5995,7 +6808,7 @@ func (x *HistoryPrefixRequest) String() string { func (*HistoryPrefixRequest) ProtoMessage() {} func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6008,7 +6821,7 @@ func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixRequest.ProtoReflect.Descriptor instead. func (*HistoryPrefixRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *HistoryPrefixRequest) GetConversationId() string { @@ -6046,7 +6859,7 @@ type HistoryPrefixResponse struct { func (x *HistoryPrefixResponse) Reset() { *x = HistoryPrefixResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6058,7 +6871,7 @@ func (x *HistoryPrefixResponse) String() string { func (*HistoryPrefixResponse) ProtoMessage() {} func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6071,7 +6884,7 @@ func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixResponse.ProtoReflect.Descriptor instead. func (*HistoryPrefixResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } func (x *HistoryPrefixResponse) GetConversationId() string { @@ -6126,7 +6939,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6138,7 +6951,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6151,7 +6964,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -6177,7 +6990,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6189,7 +7002,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6202,7 +7015,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -6222,7 +7035,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6234,7 +7047,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6247,7 +7060,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *HistoryPinRequest) GetConversationId() string { @@ -6273,7 +7086,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6285,7 +7098,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6298,7 +7111,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -6322,7 +7135,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6334,7 +7147,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6347,7 +7160,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *HistoryShareStatus) GetConversationId() string { @@ -6401,7 +7214,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6413,7 +7226,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6426,7 +7239,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -6445,7 +7258,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6457,7 +7270,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6470,7 +7283,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -6491,7 +7304,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6503,7 +7316,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6516,7 +7329,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -6549,7 +7362,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6561,7 +7374,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6574,7 +7387,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -6593,7 +7406,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6605,7 +7418,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6618,7 +7431,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -6641,7 +7454,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6653,7 +7466,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6666,7 +7479,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -6712,7 +7525,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6724,7 +7537,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6737,7 +7550,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } type HistoryWorkdirSummary struct { @@ -6751,7 +7564,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6763,7 +7576,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6776,7 +7589,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -6809,7 +7622,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6821,7 +7634,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6834,7 +7647,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -6853,7 +7666,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6865,7 +7678,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6878,7 +7691,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -6896,7 +7709,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6908,7 +7721,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6921,7 +7734,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } type HistorySyncEvent struct { @@ -6935,7 +7748,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6947,7 +7760,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6960,7 +7773,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *HistorySyncEvent) GetKind() string { @@ -6992,7 +7805,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7004,7 +7817,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7017,7 +7830,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } type ProviderListResponse struct { @@ -7029,7 +7842,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7041,7 +7854,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7054,7 +7867,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -7072,7 +7885,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7084,7 +7897,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7097,7 +7910,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } type SettingsGetResponse struct { @@ -7109,7 +7922,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7121,7 +7934,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7134,7 +7947,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -7153,7 +7966,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7165,7 +7978,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7178,7 +7991,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -7198,7 +8011,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7210,7 +8023,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7223,7 +8036,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -7250,7 +8063,7 @@ type SettingsResetSshKnownHostRequest struct { func (x *SettingsResetSshKnownHostRequest) Reset() { *x = SettingsResetSshKnownHostRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7262,7 +8075,7 @@ func (x *SettingsResetSshKnownHostRequest) String() string { func (*SettingsResetSshKnownHostRequest) ProtoMessage() {} func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7275,7 +8088,7 @@ func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsResetSshKnownHostRequest.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *SettingsResetSshKnownHostRequest) GetHost() string { @@ -7301,7 +8114,7 @@ type SettingsResetSshKnownHostResponse struct { func (x *SettingsResetSshKnownHostResponse) Reset() { *x = SettingsResetSshKnownHostResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7313,7 +8126,7 @@ func (x *SettingsResetSshKnownHostResponse) String() string { func (*SettingsResetSshKnownHostResponse) ProtoMessage() {} func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7326,7 +8139,7 @@ func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SettingsResetSshKnownHostResponse.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *SettingsResetSshKnownHostResponse) GetDeleted() uint32 { @@ -7345,7 +8158,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7357,7 +8170,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7370,7 +8183,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -7388,7 +8201,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7400,7 +8213,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7413,7 +8226,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } type SkillFilesListResponse struct { @@ -7427,7 +8240,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7439,7 +8252,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7452,7 +8265,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -7485,7 +8298,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7497,7 +8310,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7510,7 +8323,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -7530,7 +8343,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7542,7 +8355,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7555,7 +8368,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *SkillMetadataReadResponse) GetName() string { @@ -7583,7 +8396,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7595,7 +8408,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7608,7 +8421,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *SkillTextReadRequest) GetPath() string { @@ -7642,7 +8455,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7654,7 +8467,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7667,7 +8480,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *SkillTextReadResponse) GetContent() string { @@ -7693,7 +8506,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7705,7 +8518,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7718,7 +8531,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -7737,7 +8550,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7749,7 +8562,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7762,7 +8575,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } func (x *SkillManageResponse) GetResultJson() string { @@ -7783,7 +8596,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7795,7 +8608,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7808,7 +8621,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -7842,7 +8655,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7854,7 +8667,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7867,7 +8680,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} } func (x *FileMentionEntry) GetPath() string { @@ -7894,7 +8707,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7906,7 +8719,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7919,7 +8732,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -7948,7 +8761,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7960,7 +8773,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7973,7 +8786,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} } func (x *FsRoot) GetId() string { @@ -8012,7 +8825,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8024,7 +8837,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8037,7 +8850,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} } type FsRootsResponse struct { @@ -8049,7 +8862,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8061,7 +8874,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8074,7 +8887,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -8094,7 +8907,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8106,7 +8919,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8119,7 +8932,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} } func (x *FsListDirsRequest) GetPath() string { @@ -8146,7 +8959,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8158,7 +8971,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8171,7 +8984,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} } func (x *FsDirEntry) GetPath() string { @@ -8199,7 +9012,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8211,7 +9024,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8224,7 +9037,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} } func (x *FsListDirsResponse) GetPath() string { @@ -8258,7 +9071,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8270,7 +9083,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8283,7 +9096,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -8309,7 +9122,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8321,7 +9134,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8334,7 +9147,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -8357,7 +9170,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8369,7 +9182,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8382,7 +9195,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} } func (x *FsListRequest) GetWorkdir() string { @@ -8430,7 +9243,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8442,7 +9255,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8455,7 +9268,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} } func (x *FsListEntry) GetPath() string { @@ -8488,7 +9301,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8500,7 +9313,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8513,7 +9326,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} } func (x *FsListResponse) GetPath() string { @@ -8582,7 +9395,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8594,7 +9407,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8607,7 +9420,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -8638,7 +9451,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8650,7 +9463,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8663,7 +9476,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -8718,7 +9531,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8730,7 +9543,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8743,7 +9556,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -8774,7 +9587,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8786,7 +9599,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8799,7 +9612,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -8860,7 +9673,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8872,7 +9685,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8885,7 +9698,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -8959,7 +9772,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8971,7 +9784,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8984,7 +9797,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} } func (x *FsWriteTextResponse) GetPath() string { @@ -9046,7 +9859,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9058,7 +9871,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9071,7 +9884,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -9098,7 +9911,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9110,7 +9923,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9123,7 +9936,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} } func (x *FsCreateDirResponse) GetPath() string { @@ -9151,7 +9964,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9163,7 +9976,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9176,7 +9989,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} } func (x *FsRenameRequest) GetWorkdir() string { @@ -9211,7 +10024,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9223,7 +10036,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9236,7 +10049,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} } func (x *FsRenameResponse) GetFromPath() string { @@ -9270,7 +10083,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9282,7 +10095,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9295,7 +10108,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{123} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -9322,7 +10135,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9334,7 +10147,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9347,7 +10160,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{124} } func (x *FsDeleteResponse) GetPath() string { @@ -9373,7 +10186,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9385,7 +10198,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9398,7 +10211,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{125} } func (x *PingRequest) GetTimestamp() int64 { @@ -9417,7 +10230,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9429,7 +10242,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9442,7 +10255,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{126} } func (x *PongResponse) GetTimestamp() int64 { @@ -9462,7 +10275,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9474,7 +10287,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9487,7 +10300,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{127} } func (x *ErrorResponse) GetCode() int32 { @@ -9517,7 +10330,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\xc7\x1c\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\xa4\x1d\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -9564,14 +10377,15 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "gitRequest\x12d\n" + "\x15fs_read_editable_text\x18> \x01(\v2/.liveagent.gateway.v1.FsReadEditableTextRequestH\x00R\x12fsReadEditableText\x12j\n" + "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImage\x12F\n" + - "\fsftp_request\x18@ \x01(\v2!.liveagent.gateway.v1.SftpRequestH\x00R\vsftpRequest\x12S\n" + - "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + - "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + - "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12z\n" + + "\fsftp_request\x18@ \x01(\v2!.liveagent.gateway.v1.SftpRequestH\x00R\vsftpRequest\x12z\n" + "\x1dsettings_reset_ssh_known_host\x18H \x01(\v26.liveagent.gateway.v1.SettingsResetSshKnownHostRequestH\x00R\x19settingsResetSshKnownHost\x12G\n" + "\n" + - "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueueB\t\n" + - "\apayload\"\xf3#\n" + + "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueue\x12N\n" + + "\ftunnel_state\x18P \x01(\v2).liveagent.gateway.v1.TunnelStateSnapshotH\x00R\vtunnelState\x12O\n" + + "\x0ftunnel_mutation\x18Q \x01(\v2$.liveagent.gateway.v1.TunnelMutationH\x00R\x0etunnelMutation\x12F\n" + + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12V\n" + + "\x0fworkspace_watch\x18Z \x01(\v2+.liveagent.gateway.v1.WorkspaceWatchRequestH\x00R\x0eworkspaceWatchB\t\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10FJ\x04\bJ\x10K\"\xa9&\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -9622,15 +10436,18 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\n" + "sftp_event\x18J \x01(\v2\x1f.liveagent.gateway.v1.SftpEventH\x00R\tsftpEvent\x12Q\n" + "\x0fchat_queue_resp\x18K \x01(\v2'.liveagent.gateway.v1.ChatQueueResponseH\x00R\rchatQueueResp\x12P\n" + - "\x10chat_queue_event\x18L \x01(\v2$.liveagent.gateway.v1.ChatQueueEventH\x00R\x0echatQueueEvent\x12S\n" + - "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + - "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + - "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12K\n" + + "\x10chat_queue_event\x18L \x01(\v2$.liveagent.gateway.v1.ChatQueueEventH\x00R\x0echatQueueEvent\x12K\n" + "\fchat_control\x18F \x01(\v2&.liveagent.gateway.v1.ChatControlEventH\x00R\vchatControl\x12Q\n" + "\x0eruntime_status\x18G \x01(\v2(.liveagent.gateway.v1.RuntimeStatusEventH\x00R\rruntimeStatus\x12\x84\x01\n" + - "\"settings_reset_ssh_known_host_resp\x18H \x01(\v27.liveagent.gateway.v1.SettingsResetSshKnownHostResponseH\x00R\x1dsettingsResetSshKnownHostResp\x12;\n" + + "\"settings_reset_ssh_known_host_resp\x18H \x01(\v27.liveagent.gateway.v1.SettingsResetSshKnownHostResponseH\x00R\x1dsettingsResetSshKnownHostResp\x12_\n" + + "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12Q\n" + + "\x0etunnel_desired\x18P \x01(\v2(.liveagent.gateway.v1.TunnelDesiredStateH\x00R\rtunnelDesired\x12b\n" + + "\x16tunnel_mutation_result\x18Q \x01(\v2*.liveagent.gateway.v1.TunnelMutationResultH\x00R\x14tunnelMutationResult\x12F\n" + + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12Y\n" + + "\x13tunnel_probe_report\x18S \x01(\v2'.liveagent.gateway.v1.TunnelProbeReportH\x00R\x11tunnelProbeReport\x12]\n" + + "\x12workspace_activity\x18Z \x01(\v2,.liveagent.gateway.v1.WorkspaceActivityEventH\x00R\x11workspaceActivity\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + - "\apayload\"|\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10FJ\x04\bN\x10O\"|\n" + "\x11ChatSelectedModel\x12,\n" + "\x12custom_provider_id\x18\x01 \x01(\tR\x10customProviderId\x12\x14\n" + "\x05model\x18\x02 \x01(\tR\x05model\x12#\n" + @@ -9661,65 +10478,97 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\rabsolute_path\x18\x02 \x01(\tR\fabsolutePath\"O\n" + "\x1cUploadedImagePreviewResponse\x12\x1b\n" + "\tmime_type\x18\x01 \x01(\tR\bmimeType\x12\x12\n" + - "\x04data\x18\x02 \x01(\tR\x04data\"\xc3\x02\n" + - "\x14TunnelControlRequest\x12\x16\n" + - "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + - "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + - "\x04slug\x18\x03 \x01(\tR\x04slug\x12\x1d\n" + + "\x04data\x18\x02 \x01(\tR\x04data\"\xb5\x01\n" + "\n" + - "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x12\n" + - "\x04name\x18\x05 \x01(\tR\x04name\x12\x1f\n" + - "\vttl_seconds\x18\x06 \x01(\rR\n" + - "ttlSeconds\x12\x1d\n" + + "TunnelSpec\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tslug_hint\x18\x02 \x01(\tR\bslugHint\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "expires_at\x18\a \x01(\x03R\texpiresAt\x12\x1d\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + "\n" + - "public_url\x18\b \x01(\tR\tpublicUrl\x12&\n" + - "\x0fpublic_base_url\x18\t \x01(\tR\rpublicBaseUrl\x12(\n" + - "\x10project_path_key\x18\n" + - " \x01(\tR\x0eprojectPathKey\"\xef\x01\n" + - "\x15TunnelControlResponse\x12\x16\n" + - "\x06action\x18\x01 \x01(\tR\x06action\x12=\n" + - "\atunnels\x18\x02 \x03(\v2#.liveagent.gateway.v1.TunnelSummaryR\atunnels\x12;\n" + - "\x06tunnel\x18\x03 \x01(\v2#.liveagent.gateway.v1.TunnelSummaryR\x06tunnel\x12\x1d\n" + + "expires_at\x18\x05 \x01(\x03R\texpiresAt\x12(\n" + + "\x10project_path_key\x18\x06 \x01(\tR\x0eprojectPathKey\"l\n" + + "\x12TunnelDesiredState\x12:\n" + + "\atunnels\x18\x01 \x03(\v2 .liveagent.gateway.v1.TunnelSpecR\atunnels\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x04R\brevision\"\x93\x01\n" + + "\fTunnelHealth\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status\x12\x1f\n" + + "\vhttp_status\x18\x02 \x01(\rR\n" + + "httpStatus\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1d\n" + "\n" + - "error_code\x18\x04 \x01(\tR\terrorCode\x12#\n" + - "\rerror_message\x18\x05 \x01(\tR\ferrorMessage\"\xb4\x02\n" + - "\rTunnelSummary\x12\x0e\n" + + "checked_at\x18\x04 \x01(\x03R\tcheckedAt\x12\x15\n" + + "\x06rtt_ms\x18\x05 \x01(\rR\x05rttMs\"\xd7\x02\n" + + "\fTunnelStatus\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04slug\x18\x02 \x01(\tR\x04slug\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + - "\n" + - "public_url\x18\x05 \x01(\tR\tpublicUrl\x12\x1d\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1f\n" + + "\vpublic_path\x18\x05 \x01(\tR\n" + + "publicPath\x12\x1d\n" + "\n" + "created_at\x18\x06 \x01(\x03R\tcreatedAt\x12\x1d\n" + "\n" + "expires_at\x18\a \x01(\x03R\texpiresAt\x12-\n" + - "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12\x16\n" + - "\x06status\x18\t \x01(\tR\x06status\x12(\n" + - "\x10project_path_key\x18\n" + - " \x01(\tR\x0eprojectPathKey\"8\n" + + "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12(\n" + + "\x10project_path_key\x18\t \x01(\tR\x0eprojectPathKey\x128\n" + + "\x05local\x18\n" + + " \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05local\"\xcc\x01\n" + + "\x13TunnelStateSnapshot\x12<\n" + + "\atunnels\x18\x01 \x03(\v2\".liveagent.gateway.v1.TunnelStatusR\atunnels\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x04R\brevision\x12!\n" + + "\fagent_online\x18\x03 \x01(\bR\vagentOnline\x128\n" + + "\x05relay\x18\x04 \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05relay\"\xd8\x01\n" + + "\x0eTunnelMutation\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x1d\n" + + "\n" + + "target_url\x18\x03 \x01(\tR\ttargetUrl\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12$\n" + + "\vttl_seconds\x18\x05 \x01(\rH\x00R\n" + + "ttlSeconds\x88\x01\x01\x12(\n" + + "\x10project_path_key\x18\x06 \x01(\tR\x0eprojectPathKeyB\x0e\n" + + "\f_ttl_seconds\"w\n" + + "\x14TunnelMutationResult\x12\x1b\n" + + "\ttunnel_id\x18\x01 \x01(\tR\btunnelId\x12\x1d\n" + + "\n" + + "error_code\x18\x02 \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\x03 \x01(\tR\ferrorMessage\"j\n" + + "\x11TunnelProbeResult\x12\x1b\n" + + "\ttunnel_id\x18\x01 \x01(\tR\btunnelId\x128\n" + + "\x05local\x18\x02 \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05local\"V\n" + + "\x11TunnelProbeReport\x12A\n" + + "\aresults\x18\x01 \x03(\v2'.liveagent.gateway.v1.TunnelProbeResultR\aresults\"8\n" + "\fTunnelHeader\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value\"\x92\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"\xf6\x03\n" + "\vTunnelFrame\x12\x1b\n" + - "\tstream_id\x18\x01 \x01(\tR\bstreamId\x12\x1b\n" + - "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + - "\x04slug\x18\x03 \x01(\tR\x04slug\x129\n" + - "\x04kind\x18\x04 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x16\n" + - "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + - "\x04path\x18\x06 \x01(\tR\x04path\x12<\n" + - "\aheaders\x18\a \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x1f\n" + - "\vstatus_code\x18\b \x01(\rR\n" + - "statusCode\x12\x12\n" + - "\x04body\x18\t \x01(\fR\x04body\x12\x1d\n" + + "\tstream_id\x18\x01 \x01(\tR\bstreamId\x129\n" + + "\x04kind\x18\x02 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x1d\n" + "\n" + - "end_stream\x18\n" + - " \x01(\bR\tendStream\x12\x14\n" + - "\x05error\x18\v \x01(\tR\x05error\x12&\n" + - "\x0fws_message_type\x18\f \x01(\tR\rwsMessageType\"L\n" + + "target_url\x18\x03 \x01(\tR\ttargetUrl\x12\x16\n" + + "\x06method\x18\x04 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x05 \x01(\tR\x04path\x12<\n" + + "\aheaders\x18\x06 \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x16\n" + + "\x06status\x18\a \x01(\rR\x06status\x12\x12\n" + + "\x04body\x18\b \x01(\fR\x04body\x12\x14\n" + + "\x05error\x18\t \x01(\tR\x05error\x12Q\n" + + "\x0fws_message_type\x18\n" + + " \x01(\x0e2).liveagent.gateway.v1.TunnelWsMessageTypeR\rwsMessageType\x12%\n" + + "\x0ews_subprotocol\x18\v \x01(\tR\rwsSubprotocol\x12\"\n" + + "\rws_close_code\x18\f \x01(\rR\vwsCloseCode\x12&\n" + + "\x0fws_close_reason\x18\r \x01(\tR\rwsCloseReason\"3\n" + + "\x15WorkspaceWatchRequest\x12\x1a\n" + + "\bworkdirs\x18\x01 \x03(\tR\bworkdirs\"\xb3\x01\n" + + "\x16WorkspaceActivityEvent\x12\x18\n" + + "\aworkdir\x18\x01 \x01(\tR\aworkdir\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x04R\brevision\x12\x0e\n" + + "\x02fs\x18\x03 \x01(\bR\x02fs\x12\x10\n" + + "\x03git\x18\x04 \x01(\bR\x03git\x12#\n" + + "\rchanged_paths\x18\x05 \x03(\tR\fchangedPaths\x12\x1c\n" + + "\ttruncated\x18\x06 \x01(\bR\ttruncated\"L\n" + "\x13MemoryManageRequest\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x1b\n" + "\targs_json\x18\x02 \x01(\tR\bargsJson\"7\n" + @@ -9993,13 +10842,40 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\n" + "error_code\x18\a \x01(\tR\terrorCode\x12\x18\n" + "\amessage\x18\b \x01(\tR\amessage\x12\x10\n" + - "\x03seq\x18\t \x01(\x03R\x03seq\"\xa9\x01\n" + + "\x03seq\x18\t \x01(\x03R\x03seq\"\x80\x03\n" + + "\x13ChatRuntimeSnapshot\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runId\x12*\n" + + "\x11client_request_id\x18\x03 \x01(\tR\x0fclientRequestId\x12\x1b\n" + + "\tworker_id\x18\x04 \x01(\tR\bworkerId\x12\x14\n" + + "\x05state\x18\x05 \x01(\tR\x05state\x12\x10\n" + + "\x03cwd\x18\x06 \x01(\tR\x03cwd\x12\x1d\n" + + "\n" + + "updated_at\x18\a \x01(\x03R\tupdatedAt\x12\x1a\n" + + "\brevision\x18\b \x01(\x03R\brevision\x12!\n" + + "\fentries_json\x18\t \x01(\tR\ventriesJson\x12\x1f\n" + + "\vtool_status\x18\n" + + " \x01(\tR\n" + + "toolStatus\x129\n" + + "\x19tool_status_is_compaction\x18\v \x01(\bR\x16toolStatusIsCompaction\"\xb9\x02\n" + "\x12RuntimeStatusEvent\x12\x1b\n" + "\tworker_id\x18\x01 \x01(\tR\bworkerId\x12\x14\n" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x18\n" + "\avisible\x18\x03 \x01(\bR\avisible\x12(\n" + "\x10active_run_count\x18\x04 \x01(\rR\x0eactiveRunCount\x12\x1c\n" + - "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"a\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\x12D\n" + + "\vactive_runs\x18\x06 \x03(\v2#.liveagent.gateway.v1.ChatRunReportR\n" + + "activeRuns\x12H\n" + + "\rfinished_runs\x18\a \x03(\v2#.liveagent.gateway.v1.ChatRunReportR\ffinishedRuns\"\xbd\x01\n" + + "\rChatRunReport\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12\x1d\n" + + "\n" + + "error_code\x18\x04 \x01(\tR\terrorCode\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12\x1d\n" + + "\n" + + "updated_at\x18\x06 \x01(\x03R\tupdatedAt\"a\n" + "\x11CronManageRequest\x12\x16\n" + "\x06action\x18\x01 \x01(\tR\x06action\x12\x17\n" + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x1b\n" + @@ -10272,7 +11148,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\"=\n" + "\rErrorResponse\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*\xc7\x03\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xc6\x04\n" + "\x0fTunnelFrameKind\x12!\n" + "\x1dTUNNEL_FRAME_KIND_UNSPECIFIED\x10\x00\x12(\n" + "$TUNNEL_FRAME_KIND_HTTP_REQUEST_START\x10\x01\x12'\n" + @@ -10281,12 +11157,20 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "%TUNNEL_FRAME_KIND_HTTP_RESPONSE_START\x10\x04\x12(\n" + "$TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY\x10\x05\x12'\n" + "#TUNNEL_FRAME_KIND_HTTP_RESPONSE_END\x10\x06\x12\x1d\n" + - "\x19TUNNEL_FRAME_KIND_WS_OPEN\x10\a\x12\x1e\n" + - "\x1aTUNNEL_FRAME_KIND_WS_FRAME\x10\b\x12\x1e\n" + - "\x1aTUNNEL_FRAME_KIND_WS_CLOSE\x10\t\x12\x1b\n" + - "\x17TUNNEL_FRAME_KIND_ERROR\x10\n" + - "\x12\x1c\n" + - "\x18TUNNEL_FRAME_KIND_CANCEL\x10\v2\xb7\x02\n" + + "\x19TUNNEL_FRAME_KIND_WS_DIAL\x10\a\x12 \n" + + "\x1cTUNNEL_FRAME_KIND_WS_DIAL_OK\x10\b\x12#\n" + + "\x1fTUNNEL_FRAME_KIND_WS_DIAL_ERROR\x10\t\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_FRAME\x10\n" + + "\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_CLOSE\x10\v\x12\x1b\n" + + "\x17TUNNEL_FRAME_KIND_ERROR\x10\f\x12\x1c\n" + + "\x18TUNNEL_FRAME_KIND_CANCEL\x10\r\x12\x1a\n" + + "\x16TUNNEL_FRAME_KIND_PING\x10\x0e\x12\x1a\n" + + "\x16TUNNEL_FRAME_KIND_PONG\x10\x0f*\x81\x01\n" + + "\x13TunnelWsMessageType\x12&\n" + + "\"TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n" + + "\x1bTUNNEL_WS_MESSAGE_TYPE_TEXT\x10\x01\x12!\n" + + "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x022\xb7\x02\n" + "\fAgentGateway\x12^\n" + "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12p\n" + "\x14AgentTerminalConnect\x12).liveagent.gateway.v1.TerminalStreamFrame\x1a).liveagent.gateway.v1.TerminalStreamFrame(\x010\x01\x12U\n" + @@ -10304,276 +11188,298 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { return file_proto_v1_gateway_proto_rawDescData } -var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 118) +var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 128) var file_proto_v1_gateway_proto_goTypes = []any{ (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind - (ChatEvent_ChatEventType)(0), // 1: liveagent.gateway.v1.ChatEvent.ChatEventType - (*AuthRequest)(nil), // 2: liveagent.gateway.v1.AuthRequest - (*AuthResponse)(nil), // 3: liveagent.gateway.v1.AuthResponse - (*GatewayEnvelope)(nil), // 4: liveagent.gateway.v1.GatewayEnvelope - (*AgentEnvelope)(nil), // 5: liveagent.gateway.v1.AgentEnvelope - (*ChatSelectedModel)(nil), // 6: liveagent.gateway.v1.ChatSelectedModel - (*ChatRuntimeControls)(nil), // 7: liveagent.gateway.v1.ChatRuntimeControls - (*ChatUploadedFile)(nil), // 8: liveagent.gateway.v1.ChatUploadedFile - (*UploadReadableFile)(nil), // 9: liveagent.gateway.v1.UploadReadableFile - (*UploadReadableFilesRequest)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesRequest - (*UploadReadableFilesResponse)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesResponse - (*UploadedImagePreviewRequest)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewRequest - (*UploadedImagePreviewResponse)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewResponse - (*TunnelControlRequest)(nil), // 14: liveagent.gateway.v1.TunnelControlRequest - (*TunnelControlResponse)(nil), // 15: liveagent.gateway.v1.TunnelControlResponse - (*TunnelSummary)(nil), // 16: liveagent.gateway.v1.TunnelSummary - (*TunnelHeader)(nil), // 17: liveagent.gateway.v1.TunnelHeader - (*TunnelFrame)(nil), // 18: liveagent.gateway.v1.TunnelFrame - (*MemoryManageRequest)(nil), // 19: liveagent.gateway.v1.MemoryManageRequest - (*MemoryManageResponse)(nil), // 20: liveagent.gateway.v1.MemoryManageResponse - (*TerminalRequest)(nil), // 21: liveagent.gateway.v1.TerminalRequest - (*TerminalSession)(nil), // 22: liveagent.gateway.v1.TerminalSession - (*TerminalSshMetadata)(nil), // 23: liveagent.gateway.v1.TerminalSshMetadata - (*SftpRequest)(nil), // 24: liveagent.gateway.v1.SftpRequest - (*SftpEntry)(nil), // 25: liveagent.gateway.v1.SftpEntry - (*SftpTransfer)(nil), // 26: liveagent.gateway.v1.SftpTransfer - (*SftpResponse)(nil), // 27: liveagent.gateway.v1.SftpResponse - (*SftpEvent)(nil), // 28: liveagent.gateway.v1.SftpEvent - (*TerminalSshPrompt)(nil), // 29: liveagent.gateway.v1.TerminalSshPrompt - (*TerminalShellOption)(nil), // 30: liveagent.gateway.v1.TerminalShellOption - (*TerminalSshTab)(nil), // 31: liveagent.gateway.v1.TerminalSshTab - (*TerminalSshTabsSnapshot)(nil), // 32: liveagent.gateway.v1.TerminalSshTabsSnapshot - (*TerminalResponse)(nil), // 33: liveagent.gateway.v1.TerminalResponse - (*TerminalEvent)(nil), // 34: liveagent.gateway.v1.TerminalEvent - (*TerminalStreamFrame)(nil), // 35: liveagent.gateway.v1.TerminalStreamFrame - (*GitRequest)(nil), // 36: liveagent.gateway.v1.GitRequest - (*GitResponse)(nil), // 37: liveagent.gateway.v1.GitResponse - (*ChatRequest)(nil), // 38: liveagent.gateway.v1.ChatRequest - (*ChatMessageRef)(nil), // 39: liveagent.gateway.v1.ChatMessageRef - (*CancelChatRequest)(nil), // 40: liveagent.gateway.v1.CancelChatRequest - (*ChatCommandRequest)(nil), // 41: liveagent.gateway.v1.ChatCommandRequest - (*ChatQueueRequest)(nil), // 42: liveagent.gateway.v1.ChatQueueRequest - (*ChatQueueResponse)(nil), // 43: liveagent.gateway.v1.ChatQueueResponse - (*ChatQueueEvent)(nil), // 44: liveagent.gateway.v1.ChatQueueEvent - (*ChatEvent)(nil), // 45: liveagent.gateway.v1.ChatEvent - (*ChatControlEvent)(nil), // 46: liveagent.gateway.v1.ChatControlEvent - (*RuntimeStatusEvent)(nil), // 47: liveagent.gateway.v1.RuntimeStatusEvent - (*CronManageRequest)(nil), // 48: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 49: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 50: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 51: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 52: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 53: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 54: liveagent.gateway.v1.HistoryGetResponse - (*HistoryPrefixRequest)(nil), // 55: liveagent.gateway.v1.HistoryPrefixRequest - (*HistoryPrefixResponse)(nil), // 56: liveagent.gateway.v1.HistoryPrefixResponse - (*HistoryRenameRequest)(nil), // 57: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 58: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 59: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 60: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 61: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 62: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 63: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 64: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 65: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 66: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 67: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 68: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 69: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 70: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 71: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 72: liveagent.gateway.v1.HistoryDeleteResponse - (*HistorySyncEvent)(nil), // 73: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 74: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 75: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 76: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 77: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 78: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 79: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsResetSshKnownHostRequest)(nil), // 80: liveagent.gateway.v1.SettingsResetSshKnownHostRequest - (*SettingsResetSshKnownHostResponse)(nil), // 81: liveagent.gateway.v1.SettingsResetSshKnownHostResponse - (*SettingsSyncEvent)(nil), // 82: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 83: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 84: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 85: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 86: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 87: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 88: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 89: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 90: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 91: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 92: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 93: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 94: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 95: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 96: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 97: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 98: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 99: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 100: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 101: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 102: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 103: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 104: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 105: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 106: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 107: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 108: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 109: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 110: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 111: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 112: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 113: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 114: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 115: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 116: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 117: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 118: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 119: liveagent.gateway.v1.ErrorResponse + (TunnelWsMessageType)(0), // 1: liveagent.gateway.v1.TunnelWsMessageType + (ChatEvent_ChatEventType)(0), // 2: liveagent.gateway.v1.ChatEvent.ChatEventType + (*AuthRequest)(nil), // 3: liveagent.gateway.v1.AuthRequest + (*AuthResponse)(nil), // 4: liveagent.gateway.v1.AuthResponse + (*GatewayEnvelope)(nil), // 5: liveagent.gateway.v1.GatewayEnvelope + (*AgentEnvelope)(nil), // 6: liveagent.gateway.v1.AgentEnvelope + (*ChatSelectedModel)(nil), // 7: liveagent.gateway.v1.ChatSelectedModel + (*ChatRuntimeControls)(nil), // 8: liveagent.gateway.v1.ChatRuntimeControls + (*ChatUploadedFile)(nil), // 9: liveagent.gateway.v1.ChatUploadedFile + (*UploadReadableFile)(nil), // 10: liveagent.gateway.v1.UploadReadableFile + (*UploadReadableFilesRequest)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesRequest + (*UploadReadableFilesResponse)(nil), // 12: liveagent.gateway.v1.UploadReadableFilesResponse + (*UploadedImagePreviewRequest)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewRequest + (*UploadedImagePreviewResponse)(nil), // 14: liveagent.gateway.v1.UploadedImagePreviewResponse + (*TunnelSpec)(nil), // 15: liveagent.gateway.v1.TunnelSpec + (*TunnelDesiredState)(nil), // 16: liveagent.gateway.v1.TunnelDesiredState + (*TunnelHealth)(nil), // 17: liveagent.gateway.v1.TunnelHealth + (*TunnelStatus)(nil), // 18: liveagent.gateway.v1.TunnelStatus + (*TunnelStateSnapshot)(nil), // 19: liveagent.gateway.v1.TunnelStateSnapshot + (*TunnelMutation)(nil), // 20: liveagent.gateway.v1.TunnelMutation + (*TunnelMutationResult)(nil), // 21: liveagent.gateway.v1.TunnelMutationResult + (*TunnelProbeResult)(nil), // 22: liveagent.gateway.v1.TunnelProbeResult + (*TunnelProbeReport)(nil), // 23: liveagent.gateway.v1.TunnelProbeReport + (*TunnelHeader)(nil), // 24: liveagent.gateway.v1.TunnelHeader + (*TunnelFrame)(nil), // 25: liveagent.gateway.v1.TunnelFrame + (*WorkspaceWatchRequest)(nil), // 26: liveagent.gateway.v1.WorkspaceWatchRequest + (*WorkspaceActivityEvent)(nil), // 27: liveagent.gateway.v1.WorkspaceActivityEvent + (*MemoryManageRequest)(nil), // 28: liveagent.gateway.v1.MemoryManageRequest + (*MemoryManageResponse)(nil), // 29: liveagent.gateway.v1.MemoryManageResponse + (*TerminalRequest)(nil), // 30: liveagent.gateway.v1.TerminalRequest + (*TerminalSession)(nil), // 31: liveagent.gateway.v1.TerminalSession + (*TerminalSshMetadata)(nil), // 32: liveagent.gateway.v1.TerminalSshMetadata + (*SftpRequest)(nil), // 33: liveagent.gateway.v1.SftpRequest + (*SftpEntry)(nil), // 34: liveagent.gateway.v1.SftpEntry + (*SftpTransfer)(nil), // 35: liveagent.gateway.v1.SftpTransfer + (*SftpResponse)(nil), // 36: liveagent.gateway.v1.SftpResponse + (*SftpEvent)(nil), // 37: liveagent.gateway.v1.SftpEvent + (*TerminalSshPrompt)(nil), // 38: liveagent.gateway.v1.TerminalSshPrompt + (*TerminalShellOption)(nil), // 39: liveagent.gateway.v1.TerminalShellOption + (*TerminalSshTab)(nil), // 40: liveagent.gateway.v1.TerminalSshTab + (*TerminalSshTabsSnapshot)(nil), // 41: liveagent.gateway.v1.TerminalSshTabsSnapshot + (*TerminalResponse)(nil), // 42: liveagent.gateway.v1.TerminalResponse + (*TerminalEvent)(nil), // 43: liveagent.gateway.v1.TerminalEvent + (*TerminalStreamFrame)(nil), // 44: liveagent.gateway.v1.TerminalStreamFrame + (*GitRequest)(nil), // 45: liveagent.gateway.v1.GitRequest + (*GitResponse)(nil), // 46: liveagent.gateway.v1.GitResponse + (*ChatRequest)(nil), // 47: liveagent.gateway.v1.ChatRequest + (*ChatMessageRef)(nil), // 48: liveagent.gateway.v1.ChatMessageRef + (*CancelChatRequest)(nil), // 49: liveagent.gateway.v1.CancelChatRequest + (*ChatCommandRequest)(nil), // 50: liveagent.gateway.v1.ChatCommandRequest + (*ChatQueueRequest)(nil), // 51: liveagent.gateway.v1.ChatQueueRequest + (*ChatQueueResponse)(nil), // 52: liveagent.gateway.v1.ChatQueueResponse + (*ChatQueueEvent)(nil), // 53: liveagent.gateway.v1.ChatQueueEvent + (*ChatEvent)(nil), // 54: liveagent.gateway.v1.ChatEvent + (*ChatControlEvent)(nil), // 55: liveagent.gateway.v1.ChatControlEvent + (*ChatRuntimeSnapshot)(nil), // 56: liveagent.gateway.v1.ChatRuntimeSnapshot + (*RuntimeStatusEvent)(nil), // 57: liveagent.gateway.v1.RuntimeStatusEvent + (*ChatRunReport)(nil), // 58: liveagent.gateway.v1.ChatRunReport + (*CronManageRequest)(nil), // 59: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 60: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 61: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 62: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 63: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 64: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 65: liveagent.gateway.v1.HistoryGetResponse + (*HistoryPrefixRequest)(nil), // 66: liveagent.gateway.v1.HistoryPrefixRequest + (*HistoryPrefixResponse)(nil), // 67: liveagent.gateway.v1.HistoryPrefixResponse + (*HistoryRenameRequest)(nil), // 68: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 69: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 70: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 71: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 72: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 73: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 74: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 75: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 76: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 77: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 78: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 79: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 80: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 81: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 82: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 83: liveagent.gateway.v1.HistoryDeleteResponse + (*HistorySyncEvent)(nil), // 84: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 85: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 86: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 87: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 88: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 89: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 90: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsResetSshKnownHostRequest)(nil), // 91: liveagent.gateway.v1.SettingsResetSshKnownHostRequest + (*SettingsResetSshKnownHostResponse)(nil), // 92: liveagent.gateway.v1.SettingsResetSshKnownHostResponse + (*SettingsSyncEvent)(nil), // 93: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 94: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 95: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 96: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 97: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 98: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 99: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 100: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 101: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 102: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 103: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 104: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 105: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 106: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 107: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 108: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 109: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 110: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 111: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 112: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 113: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 114: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 115: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 116: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 117: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 118: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 119: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 120: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 121: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 122: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 123: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 124: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 125: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 126: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 127: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 128: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 129: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 130: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ - 41, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest - 48, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 50, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 53, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 57, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 71, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 55, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest - 59, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 62, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 64, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 66, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 68, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 74, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 76, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 78, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 83, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 85, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 87, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 91, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest - 10, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 95, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 97, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 117, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest - 12, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest - 19, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 89, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 100, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest - 21, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 102, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 109, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 111, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 113, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 115, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest - 36, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 105, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 107, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest - 24, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest - 14, // 37: liveagent.gateway.v1.GatewayEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest - 15, // 38: liveagent.gateway.v1.GatewayEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse - 18, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 80, // 40: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest - 42, // 41: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest - 45, // 42: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 49, // 43: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 51, // 44: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 54, // 45: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 58, // 46: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 72, // 47: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 73, // 48: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 56, // 49: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse - 60, // 50: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 63, // 51: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 65, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 67, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 70, // 54: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 75, // 55: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 77, // 56: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 79, // 57: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 82, // 58: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 84, // 59: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 86, // 60: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 88, // 61: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 93, // 62: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 11, // 63: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 96, // 64: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 118, // 65: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 99, // 66: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 13, // 67: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 20, // 68: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 90, // 69: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 101, // 70: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 33, // 71: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 34, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 104, // 73: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 110, // 74: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 112, // 75: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 114, // 76: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 116, // 77: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 37, // 78: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 106, // 79: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 108, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 27, // 81: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse - 28, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent - 43, // 83: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse - 44, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent - 14, // 85: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest - 15, // 86: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse - 18, // 87: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 46, // 88: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent - 47, // 89: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent - 81, // 90: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse - 119, // 91: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 9, // 92: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 8, // 93: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 94: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary - 16, // 95: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary - 0, // 96: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind - 17, // 97: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader - 23, // 98: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata - 25, // 99: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry - 25, // 100: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry - 26, // 101: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 26, // 102: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 31, // 103: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab - 22, // 104: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 22, // 105: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 30, // 106: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 29, // 107: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt - 32, // 108: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 22, // 109: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 32, // 110: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 22, // 111: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession - 6, // 112: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 8, // 113: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 7, // 114: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 38, // 115: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest - 39, // 116: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 40, // 117: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest - 1, // 118: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 52, // 119: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 52, // 120: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 39, // 121: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 52, // 122: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 52, // 123: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 52, // 124: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 61, // 125: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 61, // 126: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 52, // 127: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 69, // 128: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 52, // 129: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 92, // 130: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 94, // 131: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 98, // 132: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 103, // 133: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 5, // 134: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 35, // 135: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame - 2, // 136: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 4, // 137: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 35, // 138: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame - 3, // 139: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 137, // [137:140] is the sub-list for method output_type - 134, // [134:137] is the sub-list for method input_type - 134, // [134:134] is the sub-list for extension type_name - 134, // [134:134] is the sub-list for extension extendee - 0, // [0:134] is the sub-list for field type_name + 50, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest + 59, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 61, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 64, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 68, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 82, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 66, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest + 70, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 73, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 75, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 77, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 79, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 85, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 87, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 89, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 94, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 96, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 98, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 102, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 11, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest + 106, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 108, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 128, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 13, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest + 28, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest + 100, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 111, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 30, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest + 113, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 120, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 122, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 124, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 126, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 45, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest + 116, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 118, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 33, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest + 91, // 37: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest + 51, // 38: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest + 19, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_state:type_name -> liveagent.gateway.v1.TunnelStateSnapshot + 20, // 40: liveagent.gateway.v1.GatewayEnvelope.tunnel_mutation:type_name -> liveagent.gateway.v1.TunnelMutation + 25, // 41: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 26, // 42: liveagent.gateway.v1.GatewayEnvelope.workspace_watch:type_name -> liveagent.gateway.v1.WorkspaceWatchRequest + 54, // 43: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 60, // 44: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 62, // 45: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 65, // 46: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 69, // 47: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 83, // 48: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 84, // 49: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 67, // 50: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse + 71, // 51: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 74, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 76, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 78, // 54: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 81, // 55: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 86, // 56: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 88, // 57: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 90, // 58: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 93, // 59: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 95, // 60: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 97, // 61: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 99, // 62: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 104, // 63: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 12, // 64: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 107, // 65: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 129, // 66: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 110, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 14, // 68: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 29, // 69: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 101, // 70: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 112, // 71: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 42, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 43, // 73: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 115, // 74: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 121, // 75: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 123, // 76: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 125, // 77: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 127, // 78: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 46, // 79: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 117, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 119, // 81: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 36, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse + 37, // 83: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent + 52, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse + 53, // 85: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent + 55, // 86: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent + 57, // 87: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent + 92, // 88: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse + 56, // 89: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot + 16, // 90: liveagent.gateway.v1.AgentEnvelope.tunnel_desired:type_name -> liveagent.gateway.v1.TunnelDesiredState + 21, // 91: liveagent.gateway.v1.AgentEnvelope.tunnel_mutation_result:type_name -> liveagent.gateway.v1.TunnelMutationResult + 25, // 92: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 23, // 93: liveagent.gateway.v1.AgentEnvelope.tunnel_probe_report:type_name -> liveagent.gateway.v1.TunnelProbeReport + 27, // 94: liveagent.gateway.v1.AgentEnvelope.workspace_activity:type_name -> liveagent.gateway.v1.WorkspaceActivityEvent + 130, // 95: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 10, // 96: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 9, // 97: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 15, // 98: liveagent.gateway.v1.TunnelDesiredState.tunnels:type_name -> liveagent.gateway.v1.TunnelSpec + 17, // 99: liveagent.gateway.v1.TunnelStatus.local:type_name -> liveagent.gateway.v1.TunnelHealth + 18, // 100: liveagent.gateway.v1.TunnelStateSnapshot.tunnels:type_name -> liveagent.gateway.v1.TunnelStatus + 17, // 101: liveagent.gateway.v1.TunnelStateSnapshot.relay:type_name -> liveagent.gateway.v1.TunnelHealth + 17, // 102: liveagent.gateway.v1.TunnelProbeResult.local:type_name -> liveagent.gateway.v1.TunnelHealth + 22, // 103: liveagent.gateway.v1.TunnelProbeReport.results:type_name -> liveagent.gateway.v1.TunnelProbeResult + 0, // 104: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 24, // 105: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 1, // 106: liveagent.gateway.v1.TunnelFrame.ws_message_type:type_name -> liveagent.gateway.v1.TunnelWsMessageType + 32, // 107: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata + 34, // 108: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry + 34, // 109: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry + 35, // 110: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 35, // 111: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 40, // 112: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab + 31, // 113: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 31, // 114: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 39, // 115: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 38, // 116: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt + 41, // 117: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 31, // 118: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 41, // 119: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 31, // 120: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession + 7, // 121: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 9, // 122: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 8, // 123: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 47, // 124: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest + 48, // 125: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 49, // 126: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest + 2, // 127: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 58, // 128: liveagent.gateway.v1.RuntimeStatusEvent.active_runs:type_name -> liveagent.gateway.v1.ChatRunReport + 58, // 129: liveagent.gateway.v1.RuntimeStatusEvent.finished_runs:type_name -> liveagent.gateway.v1.ChatRunReport + 63, // 130: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 131: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 48, // 132: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 63, // 133: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 134: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 135: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 72, // 136: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 72, // 137: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 63, // 138: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 80, // 139: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 63, // 140: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 103, // 141: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 105, // 142: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 109, // 143: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 114, // 144: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 6, // 145: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 44, // 146: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame + 3, // 147: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 5, // 148: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 44, // 149: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame + 4, // 150: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 148, // [148:151] is the sub-list for method output_type + 145, // [145:148] is the sub-list for method input_type + 145, // [145:145] is the sub-list for extension type_name + 145, // [145:145] is the sub-list for extension extendee + 0, // [0:145] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -10619,11 +11525,12 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_FsReadEditableText)(nil), (*GatewayEnvelope_FsReadWorkspaceImage)(nil), (*GatewayEnvelope_SftpRequest)(nil), - (*GatewayEnvelope_TunnelControl)(nil), - (*GatewayEnvelope_TunnelControlResp)(nil), - (*GatewayEnvelope_TunnelFrame)(nil), (*GatewayEnvelope_SettingsResetSshKnownHost)(nil), (*GatewayEnvelope_ChatQueue)(nil), + (*GatewayEnvelope_TunnelState)(nil), + (*GatewayEnvelope_TunnelMutation)(nil), + (*GatewayEnvelope_TunnelFrame)(nil), + (*GatewayEnvelope_WorkspaceWatch)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -10669,22 +11576,26 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_SftpEvent)(nil), (*AgentEnvelope_ChatQueueResp)(nil), (*AgentEnvelope_ChatQueueEvent)(nil), - (*AgentEnvelope_TunnelControl)(nil), - (*AgentEnvelope_TunnelControlResp)(nil), - (*AgentEnvelope_TunnelFrame)(nil), (*AgentEnvelope_ChatControl)(nil), (*AgentEnvelope_RuntimeStatus)(nil), (*AgentEnvelope_SettingsResetSshKnownHostResp)(nil), + (*AgentEnvelope_ChatRuntimeSnapshot)(nil), + (*AgentEnvelope_TunnelDesired)(nil), + (*AgentEnvelope_TunnelMutationResult)(nil), + (*AgentEnvelope_TunnelFrame)(nil), + (*AgentEnvelope_TunnelProbeReport)(nil), + (*AgentEnvelope_WorkspaceActivity)(nil), (*AgentEnvelope_Error)(nil), } - file_proto_v1_gateway_proto_msgTypes[62].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[17].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[72].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), - NumEnums: 2, - NumMessages: 118, + NumEnums: 3, + NumMessages: 128, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/chat_commands.go b/crates/agent-gateway/internal/server/chat_commands.go index e6a822008..f1ed95d9b 100644 --- a/crates/agent-gateway/internal/server/chat_commands.go +++ b/crates/agent-gateway/internal/server/chat_commands.go @@ -26,14 +26,6 @@ type chatCommandMessageRef struct { ContentHash string `json:"content_hash"` } -type chatCommandStart struct { - RunID string - ConversationID string - AcceptedSeq int64 - Created bool - State string -} - func newChatTraceID() string { return strings.ReplaceAll(uuid.NewString(), "-", "") } @@ -90,50 +82,21 @@ func normalizeChatQueuePolicy(value string) string { } } -func startAcceptedChatCommand( - sm *session.Manager, - requestID string, - body handler.ChatRequestBody, - initialPayloads []map[string]any, -) (chatCommandStart, error) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - requestID = "chat-command-" + uuid.NewString() - } - snapshot, created, acceptedSeq, err := sm.StartAcceptedChatCommandRun( - requestID, - body.ConversationID, - body.ClientRequestID, - body.Workdir, - initialPayloads, - ) - if err != nil { - return chatCommandStart{}, err - } - runID := snapshot.RequestID - if runID == "" { - runID = requestID - } - return chatCommandStart{ - RunID: runID, - ConversationID: strings.TrimSpace(snapshot.ConversationID), - AcceptedSeq: acceptedSeq, - Created: created, - State: strings.TrimSpace(snapshot.State), - }, nil -} - +// dispatchAcceptedChatCommand delivers the accepted command to the agent and +// arms the startup watchdog. cleanupWatch closes the caller's command-update +// watch once the command has either settled or been failed. func dispatchAcceptedChatCommand( parent context.Context, cfg *config.Config, sm *session.Manager, - start chatCommandStart, + cleanupWatch func(), + start session.ChatCommandStart, body handler.ChatRequestBody, baseMessageRef *chatCommandMessageRef, traceID string, ) { - if !start.Created { - return + if cleanupWatch != nil { + defer cleanupWatch() } timeout := 2 * time.Minute if cfg != nil && cfg.RequestTimeout > 0 { @@ -147,13 +110,20 @@ func dispatchAcceptedChatCommand( commandType = "chat.edit_resend" } if err := sm.SendToAgentContext(ctx, buildChatCommandEnvelope(start.RunID, commandType, body, baseMessageRef)); err != nil { - failAcceptedChatCommand(sm, start.RunID, body.ConversationID, "desktop_runtime_unavailable", err) + message := "chat command failed" + if err != nil && strings.TrimSpace(err.Error()) != "" { + message = strings.TrimSpace(err.Error()) + } + sm.FailChatCommand(start.RunID, "desktop_runtime_unavailable", message) return } logChatCommandSpan(traceID, "command_delivered", start.RunID, start.ConversationID, body.ClientRequestID, commandType) watchAcceptedChatCommandStartup(parent, cfg, sm, start.RunID) } +// watchAcceptedChatCommandStartup fails a command whose run never settled +// (started, finished, or parked in the desktop prompt queue) within the +// configured startup window. func watchAcceptedChatCommandStartup( parent context.Context, cfg *config.Config, @@ -166,13 +136,17 @@ func watchAcceptedChatCommandStartup( if !waitChatCommandWatchdog(parent, chatStartTimeout(cfg)) { return } - if sm.FailStartingChatRun(runID, "Desktop backend did not accept the remote chat request. Please retry.") { + if sm.ChatCommandSettled(runID) { return } if !waitChatCommandWatchdog(parent, chatRenderStartTimeout(cfg)) { return } - sm.FailUnstartedChatRun(runID, "Desktop app accepted the remote chat request but did not start it. Please retry.") + if sm.ChatCommandSettled(runID) { + return + } + sm.FailChatCommand(runID, "startup_timeout", + "The desktop app did not start the remote chat request. Please retry.") } func waitChatCommandWatchdog(ctx context.Context, timeout time.Duration) bool { @@ -210,7 +184,7 @@ func buildAcceptedChatCommandPayloads( payloads := make([]map[string]any, 0, 2) if baseMessageRef != nil { payloads = append(payloads, map[string]any{ - "type": "rebased", + "type": session.StreamEventRebased, "base_message_ref": baseMessageRef, "reason": "edit_resend", }) @@ -240,20 +214,6 @@ func buildUserMessageAppendedPayload( return payload } -func failAcceptedChatCommand( - sm *session.Manager, - runID string, - conversationID string, - errorCode string, - err error, -) { - message := "chat command failed" - if err != nil && strings.TrimSpace(err.Error()) != "" { - message = strings.TrimSpace(err.Error()) - } - sm.MarkChatRunControl(runID, conversationID, "failed", errorCode, message) -} - func buildChatCommandEnvelope( requestID string, commandType string, diff --git a/crates/agent-gateway/internal/server/chat_payloads.go b/crates/agent-gateway/internal/server/chat_payloads.go deleted file mode 100644 index e8d78a7fe..000000000 --- a/crates/agent-gateway/internal/server/chat_payloads.go +++ /dev/null @@ -1,105 +0,0 @@ -package server - -import ( - "encoding/json" - "strings" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func isTerminalChatControlPayload(control *gatewayv1.ChatControlEvent) bool { - switch strings.TrimSpace(control.GetState()) { - case "completed", "failed", "cancelled": - return true - default: - return false - } -} - -func chatControlPayload( - control *gatewayv1.ChatControlEvent, - seq int64, - workdirInput ...string, -) map[string]any { - payload := map[string]any{ - "type": strings.TrimSpace(control.GetType()), - "client_request_id": strings.TrimSpace(control.GetClientRequestId()), - "conversation_id": strings.TrimSpace(control.GetConversationId()), - "run_epoch": control.GetRunEpoch(), - "state": strings.TrimSpace(control.GetState()), - } - if seq > 0 { - payload["seq"] = seq - } else if control.GetSeq() > 0 { - payload["seq"] = control.GetSeq() - } - if errorCode := strings.TrimSpace(control.GetErrorCode()); errorCode != "" { - payload["error_code"] = errorCode - } - if message := strings.TrimSpace(control.GetMessage()); message != "" { - payload["message"] = message - } - if len(workdirInput) > 0 { - if workdir := strings.TrimSpace(workdirInput[0]); workdir != "" { - payload["workdir"] = workdir - } - } - return payload -} - -func chatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { - payload := map[string]any{ - "type": chatEventType(event.GetType()), - } - if seq > 0 { - payload["seq"] = seq - } - if len(workdirInput) > 0 { - if workdir := strings.TrimSpace(workdirInput[0]); workdir != "" { - payload["workdir"] = workdir - } - } - - raw := strings.TrimSpace(event.GetData()) - if raw == "" { - raw = "{}" - } - - var decoded map[string]any - if err := json.Unmarshal([]byte(raw), &decoded); err == nil { - for key, value := range decoded { - payload[key] = value - } - } - - if conversationID := strings.TrimSpace(event.GetConversationId()); conversationID != "" { - payload["conversation_id"] = conversationID - } - - return payload -} - -func chatEventType(eventType gatewayv1.ChatEvent_ChatEventType) string { - switch eventType { - case gatewayv1.ChatEvent_TOKEN: - return "token" - case gatewayv1.ChatEvent_THINKING: - return "thinking" - case gatewayv1.ChatEvent_TOOL_CALL: - return "tool_call" - case gatewayv1.ChatEvent_TOOL_RESULT: - return "tool_result" - case gatewayv1.ChatEvent_DONE: - return "done" - case gatewayv1.ChatEvent_ERROR: - return "error" - case gatewayv1.ChatEvent_TOOL_STATUS: - return "tool_status" - case gatewayv1.ChatEvent_HOSTED_SEARCH: - return "hosted_search" - case gatewayv1.ChatEvent_USER_MESSAGE: - return "user_message" - default: - return "message" - } -} diff --git a/crates/agent-gateway/internal/server/chat_rate_limiter.go b/crates/agent-gateway/internal/server/chat_rate_limiter.go deleted file mode 100644 index 37027f929..000000000 --- a/crates/agent-gateway/internal/server/chat_rate_limiter.go +++ /dev/null @@ -1,67 +0,0 @@ -package server - -import ( - "net" - "net/http" - "strings" - "sync" - "time" -) - -type chatRateLimiter struct { - mu sync.Mutex - buckets map[string]chatRateLimitBucket -} - -type chatRateLimitBucket struct { - windowStart time.Time - count int -} - -func newChatRateLimiter() *chatRateLimiter { - return &chatRateLimiter{ - buckets: make(map[string]chatRateLimitBucket), - } -} - -func (l *chatRateLimiter) allow(key string, limit int, window time.Duration, now time.Time) bool { - if l == nil || limit <= 0 || window <= 0 { - return true - } - key = strings.TrimSpace(key) - if key == "" { - key = "unknown" - } - l.mu.Lock() - defer l.mu.Unlock() - for bucketKey, bucket := range l.buckets { - if now.Sub(bucket.windowStart) > 2*window { - delete(l.buckets, bucketKey) - } - } - bucket := l.buckets[key] - if bucket.windowStart.IsZero() || now.Sub(bucket.windowStart) >= window { - l.buckets[key] = chatRateLimitBucket{windowStart: now, count: 1} - return true - } - if bucket.count >= limit { - return false - } - bucket.count += 1 - l.buckets[key] = bucket - return true -} - -func chatRateLimitKey(r *http.Request, scope string) string { - host := "" - if r != nil { - host = strings.TrimSpace(r.RemoteAddr) - } - if parsedHost, _, err := net.SplitHostPort(host); err == nil { - host = parsedHost - } - if host == "" { - host = "unknown" - } - return strings.TrimSpace(scope) + ":" + host -} diff --git a/crates/agent-gateway/internal/server/chat_rate_limiter_test.go b/crates/agent-gateway/internal/server/chat_rate_limiter_test.go deleted file mode 100644 index c110dde97..000000000 --- a/crates/agent-gateway/internal/server/chat_rate_limiter_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package server - -import ( - "testing" - "time" -) - -func TestChatRateLimiterFixedWindow(t *testing.T) { - limiter := newChatRateLimiter() - now := time.Unix(100, 0) - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now) { - t.Fatal("first request should be allowed") - } - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(time.Second)) { - t.Fatal("second request should be allowed") - } - if limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(2*time.Second)) { - t.Fatal("third request should be rate limited") - } - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(time.Minute)) { - t.Fatal("request in next window should be allowed") - } -} diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go index 5624f67fd..311d03ddf 100644 --- a/crates/agent-gateway/internal/server/grpc.go +++ b/crates/agent-gateway/internal/server/grpc.go @@ -4,7 +4,6 @@ import ( "context" "errors" "io" - "log" "strings" "time" @@ -67,9 +66,22 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ } }() + pings := sess.Pings() sendErrCh := make(chan error, 1) go func() { for { + // Heartbeats jump the shared data queue so congestion can never + // starve them. + select { + case ping := <-pings: + if err := stream.Send(ping); err != nil { + sendErrCh <- err + cancel() + return + } + continue + default: + } select { case <-ctx.Done(): sendErrCh <- ctx.Err() @@ -78,6 +90,12 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ sendErrCh <- nil cancel() return + case ping := <-pings: + if err := stream.Send(ping); err != nil { + sendErrCh <- err + cancel() + return + } case outbound := <-toAgent: if outbound == nil || outbound.GatewayEnvelope == nil { continue @@ -119,8 +137,10 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ return err } + // Any inbound envelope proves the agent is alive; a streaming agent + // must never be declared heartbeat-stale. + s.sm.TouchHeartbeat(sess) if env.GetPong() != nil { - s.sm.TouchHeartbeat(sess) continue } @@ -239,7 +259,7 @@ func (s *GRPCServer) heartbeatPeriod() time.Duration { } func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { - ok, err := sess.TrySendToAgent(&gatewayv1.GatewayEnvelope{ + return sess.SendPing(&gatewayv1.GatewayEnvelope{ RequestId: "ping-" + uuid.NewString(), Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_Ping{ @@ -247,16 +267,5 @@ func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { Timestamp: time.Now().Unix(), }, }, - }) - if errors.Is(err, session.ErrAgentOffline) { - return false - } - if err != nil { - log.Printf("send heartbeat failed: %v", err) - return false - } - if !ok { - log.Printf("skip heartbeat: outbound queue is full") - } - return true + }) == nil } diff --git a/crates/agent-gateway/internal/server/grpc_test.go b/crates/agent-gateway/internal/server/grpc_test.go index e80208490..9f9029278 100644 --- a/crates/agent-gateway/internal/server/grpc_test.go +++ b/crates/agent-gateway/internal/server/grpc_test.go @@ -70,3 +70,69 @@ func TestAgentTerminalConnectSendsReadyFrame(t *testing.T) { t.Fatal("gRPC server did not stop") } } + +func TestAgentConnectTouchesHeartbeatOnAnyEnvelope(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + sm := session.NewManager() + grpcServer := grpc.NewServer() + gatewayv1.RegisterAgentGatewayServer( + grpcServer, + NewGRPCServer(&config.Config{HeartbeatPeriod: 100 * time.Millisecond}, sm), + ) + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + go func() { + _ = grpcServer.Serve(listener) + }() + + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("dial bufconn gRPC: %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := gatewayv1.NewAgentGatewayClient(conn).AgentConnect(ctx) + if err != nil { + t.Fatalf("open agent stream: %v", err) + } + + // The dedicated lane must deliver pings regardless of data-queue state. + if _, err := stream.Recv(); err != nil { + t.Fatalf("receive initial ping: %v", err) + } + + // Never send a Pong: non-pong traffic alone must keep the session alive + // through several staleness windows (timeout = 3 x 100ms period). + deadline := time.Now().Add(700 * time.Millisecond) + for time.Now().Before(deadline) { + if err := stream.Send(&gatewayv1.AgentEnvelope{RequestId: "activity"}); err != nil { + t.Fatalf("send activity envelope: %v", err) + } + if !sm.IsOnline() { + t.Fatalf("session cleared while agent was actively sending") + } + time.Sleep(50 * time.Millisecond) + } + + // Once traffic stops, the staleness sweep must still clear the session. + waitUntil := time.Now().Add(2 * time.Second) + for sm.IsOnline() { + if time.Now().After(waitUntil) { + t.Fatalf("session not cleared after inbound traffic stopped") + } + time.Sleep(25 * time.Millisecond) + } +} diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index f992cd4d7..e5f0c7d7a 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -38,11 +38,8 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { rootMux.HandleFunc("GET /api/public/history-shares/{token}", publicHistoryShare(cfg, sm)) apiMux := http.NewServeMux() - chatLimiter := newChatRateLimiter() apiMux.HandleFunc("GET /api/status", handler.Status(sm)) apiMux.HandleFunc("POST /api/files/import", handler.ImportReadableFiles(sm, cfg.RequestTimeout)) - apiMux.HandleFunc("POST /api/chat/commands", chatCommandsHTTP(cfg, sm, chatLimiter)) - apiMux.HandleFunc("GET /api/chat/events", chatEventsHTTP(cfg, sm, chatLimiter)) rootMux.Handle("/api/", auth.HTTPMiddleware(cfg.Token, apiMux)) webFS, err := fs.Sub(gateway.WebUIAssets, "web/dist") diff --git a/crates/agent-gateway/internal/server/http_chat.go b/crates/agent-gateway/internal/server/http_chat.go deleted file mode 100644 index 7e4aa899d..000000000 --- a/crates/agent-gateway/internal/server/http_chat.go +++ /dev/null @@ -1,495 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -const maxChatCommandBytes = 2 * 1024 * 1024 -const maxChatCommandsPerMinute = 120 -const maxChatEventStreamsPerMinute = 240 - -func chatCommandsHTTP(cfg *config.Config, sm *session.Manager, limiter *chatRateLimiter) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !allowStateChangingRequest(w, r) { - return - } - if limiter != nil && !limiter.allow(chatRateLimitKey(r, "chat.commands"), maxChatCommandsPerMinute, time.Minute, time.Now()) { - writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "chat command rate limit exceeded"}) - return - } - raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxChatCommandBytes)) - if err != nil { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "chat command payload is too large"}) - return - } - commandType, body, baseMessageRef, err := decodeChatCommandPayload(raw) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat command payload"}) - return - } - - switch commandType { - case "chat.submit": - baseMessageRef = nil - case "chat.edit_resend": - if baseMessageRef == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "base_message_ref is required"}) - return - } - if err := validateChatMessageRef(baseMessageRef); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) - return - } - case "chat.cancel": - handleChatCancelCommandHTTP(w, r, cfg, sm, raw) - return - default: - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "unsupported chat command"}) - return - } - - if err := normalizeChatRequestBody(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) - return - } - traceID := newChatTraceID() - logChatCommandSpan(traceID, "command_received", "", body.ConversationID, body.ClientRequestID, commandType) - if !sm.IsOnline() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "agent offline"}) - return - } - - requestID := "chat-command-" + uuid.NewString() - initialPayloads := buildAcceptedChatCommandPayloads(body, baseMessageRef) - start, err := startAcceptedChatCommand(sm, requestID, body, initialPayloads) - if err != nil { - writeJSON(w, http.StatusConflict, map[string]any{"error": websocketErrorMessage(err)}) - return - } - if start.Created { - logChatCommandSpan(traceID, "initial_persist_done", start.RunID, start.ConversationID, body.ClientRequestID, commandType) - go dispatchAcceptedChatCommand(context.Background(), cfg, sm, start, body, baseMessageRef, traceID) - } else { - logChatCommandSpan(traceID, "command_deduped", start.RunID, start.ConversationID, body.ClientRequestID, commandType) - } - - writeJSON(w, http.StatusAccepted, map[string]any{ - "run_id": start.RunID, - "conversation_id": start.ConversationID, - "client_request_id": body.ClientRequestID, - "accepted_seq": start.AcceptedSeq, - "state": start.State, - "deduped": !start.Created, - }) - } -} - -func handleChatCancelCommandHTTP( - w http.ResponseWriter, - r *http.Request, - cfg *config.Config, - sm *session.Manager, - raw []byte, -) { - type cancelPayload struct { - Type string `json:"type"` - Payload *struct { - RunID string `json:"run_id"` - ConversationID string `json:"conversation_id"` - } `json:"payload"` - } - var payload cancelPayload - if err := decodeStrictJSON(raw, &payload); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat.cancel payload"}) - return - } - if strings.TrimSpace(payload.Type) != "chat.cancel" || payload.Payload == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat.cancel payload"}) - return - } - conversationID := strings.TrimSpace(payload.Payload.ConversationID) - runID := strings.TrimSpace(payload.Payload.RunID) - if conversationID == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "conversation_id is required"}) - return - } - if !sm.IsOnline() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "agent offline"}) - return - } - if runID == "" { - if snapshot, ok := sm.RunningChatRunSnapshot(conversationID); ok { - runID = strings.TrimSpace(snapshot.RequestID) - if conversationID == "" { - conversationID = strings.TrimSpace(snapshot.ConversationID) - } - } - } - if runID == "" { - writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "run_id": "", "conversation_id": conversationID}) - return - } - requestID := runID - timeout := 10 * time.Second - if cfg != nil && cfg.WebSocketWriteTimeout > 0 { - timeout = cfg.WebSocketWriteTimeout - } - ctx, cancel := context.WithTimeout(r.Context(), timeout) - defer cancel() - if err := sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: buildChatCancelCommandPayload(conversationID), - }); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]any{"error": websocketErrorMessage(err)}) - return - } - if runID != "" { - sm.MarkChatRunControl(runID, conversationID, "cancelled", "", "") - } - writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "run_id": runID, "conversation_id": conversationID}) -} - -func chatEventsHTTP(_ *config.Config, sm *session.Manager, limiter *chatRateLimiter) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !originAllowed(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden origin"}) - return - } - if limiter != nil && !limiter.allow(chatRateLimitKey(r, "chat.events"), maxChatEventStreamsPerMinute, time.Minute, time.Now()) { - writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "chat event stream rate limit exceeded"}) - return - } - - requestID := strings.TrimSpace(r.URL.Query().Get("run_id")) - conversationID := strings.TrimSpace(r.URL.Query().Get("conversation_id")) - afterSeq := parseAfterSeq(r.URL.Query().Get("after_seq")) - if afterSeq <= 0 { - afterSeq = parseAfterSeq(r.Header.Get("Last-Event-ID")) - } - if requestID == "" && conversationID == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "run_id or conversation_id is required"}) - return - } - - eventCh, eventDone, cleanup, snapshot, err := sm.SubscribeChatRun(requestID, conversationID, afterSeq) - if err != nil { - status := http.StatusNotFound - if !errors.Is(err, session.ErrChatRunNotFound) { - status = http.StatusBadGateway - } - writeJSON(w, status, map[string]any{"error": websocketErrorMessage(err)}) - return - } - defer cleanup() - - w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - flusher, _ := w.(http.Flusher) - if flusher != nil { - flusher.Flush() - } - - heartbeat := time.NewTicker(15 * time.Second) - defer heartbeat.Stop() - for { - select { - case <-r.Context().Done(): - return - case <-eventDone: - return - case <-heartbeat.C: - if _, err := io.WriteString(w, ": keep-alive\n\n"); err != nil { - return - } - if flusher != nil { - flusher.Flush() - } - case event, ok := <-eventCh: - if !ok { - return - } - terminal, err := writeChatSSE(w, flusher, snapshot.RequestID, event) - if err != nil { - return - } - if terminal && strings.TrimSpace(event.RequestID) == strings.TrimSpace(snapshot.RequestID) { - return - } - } - } - } -} - -func writeChatSSE( - w io.Writer, - flusher http.Flusher, - snapshotRunID string, - event *session.ChatBroadcastEvent, -) (bool, error) { - if event == nil { - return false, nil - } - payload, terminal := chatBroadcastPayload(event) - if payload == nil { - return false, nil - } - seq := event.Seq - runID := strings.TrimSpace(event.RequestID) - if runID == "" { - runID = strings.TrimSpace(snapshotRunID) - } - conversationID, _ := payload["conversation_id"].(string) - envelope := map[string]any{ - "specversion": "1.0", - "id": fmt.Sprintf("%s/%d", runID, seq), - "source": "liveagent.gateway.chat", - "type": cloudChatEventType(payload), - "time": time.Now().UTC().Format(time.RFC3339Nano), - "run_id": runID, - "snapshot_run_id": strings.TrimSpace(snapshotRunID), - "conversation_id": strings.TrimSpace(conversationID), - "seq": seq, - "payload": payload, - } - data, err := json.Marshal(envelope) - if err != nil { - return terminal, err - } - eventName := "chat.event" - if isChatControlPayload(payload) { - eventName = "chat.control" - } - if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", seq, eventName, data); err != nil { - return terminal, err - } - if flusher != nil { - flusher.Flush() - } - return terminal, nil -} - -func chatBroadcastPayload(event *session.ChatBroadcastEvent) (map[string]any, bool) { - if len(event.Payload) > 0 { - payload := cloneChatPayload(event.Payload) - if seq := event.Seq; seq > 0 { - payload["seq"] = seq - } - if len(event.Workdir) > 0 { - if workdir := strings.TrimSpace(event.Workdir); workdir != "" { - payload["workdir"] = workdir - } - } - return publicChatPayload(payload), isTerminalChatPayload(payload) - } - if event.Control != nil { - payload := chatControlPayload(event.Control, event.Seq, event.Workdir) - return publicChatPayload(payload), isTerminalChatControlPayload(event.Control) - } - if event.Event != nil { - payload := chatEventPayload(event.Event, event.Seq, event.Workdir) - return publicChatPayload(payload), event.Event.GetType() == gatewayv1.ChatEvent_DONE || - event.Event.GetType() == gatewayv1.ChatEvent_ERROR - } - return nil, false -} - -func isTerminalChatPayload(payload map[string]any) bool { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "done", "completed", "error", "failed", "cancelled": - return true - default: - return false - } -} - -func cloudChatEventType(payload map[string]any) string { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "accepted": - return "run.accepted" - case "user_message": - return "user.message.appended" - case "rebased": - return "conversation.rebased" - case "projection_updated": - return "projection.updated" - case "delivered", "claimed", "starting", "progress": - return "context.preparing" - case "started": - return "runtime.started" - case "token": - return "assistant.delta" - case "thinking": - return "thinking.delta" - case "tool_call": - return "tool.call" - case "tool_call_delta": - return "tool.call.delta" - case "tool_result": - return "tool.result" - case "done", "completed": - return "run.completed" - case "error", "failed": - return "run.failed" - case "cancelled": - return "run.cancelled" - default: - return "chat.event" - } -} - -func isChatControlPayload(payload map[string]any) bool { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "accepted", "user_message", "rebased", "projection_updated", "delivered", "claimed", "starting", "queued_in_gui", "started", "progress", "completed", "failed", "cancelled": - return true - default: - return false - } -} - -func cloneChatPayload(input map[string]any) map[string]any { - if len(input) == 0 { - return map[string]any{} - } - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out -} - -func publicChatPayload(payload map[string]any) map[string]any { - delete(payload, "request_id") - return payload -} - -func parseAfterSeq(value string) int64 { - value = strings.TrimSpace(value) - if value == "" { - return 0 - } - if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { - return parsed - } - return 0 -} - -func allowStateChangingRequest(w http.ResponseWriter, r *http.Request) bool { - if !originAllowed(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden origin"}) - return false - } - if strings.TrimSpace(r.Header.Get("X-LiveAgent-CSRF")) == "" { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "missing csrf header"}) - return false - } - return true -} - -func originAllowed(r *http.Request) bool { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" { - return true - } - parsed, err := url.Parse(origin) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return false - } - requestURL := requestURLForOriginCheck(r) - if requestURL == nil { - return false - } - if sameOrigin(parsed, requestURL) { - return true - } - originHost := strings.TrimSpace(parsed.Hostname()) - requestHost := strings.TrimSpace(requestURL.Hostname()) - if originHost == "" || requestHost == "" { - return false - } - return isLoopbackHost(originHost) && isLoopbackHost(requestHost) -} - -func requestURLForOriginCheck(r *http.Request) *url.URL { - if r == nil { - return nil - } - scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) - switch scheme { - case "http", "https": - default: - return nil - } - host := strings.TrimSpace(r.Host) - if host == "" { - return nil - } - return &url.URL{Scheme: scheme, Host: host} -} - -func sameOrigin(a *url.URL, b *url.URL) bool { - if a == nil || b == nil { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { - return false - } - return originPort(a) == originPort(b) -} - -func originPort(u *url.URL) string { - if u == nil { - return "" - } - if port := strings.TrimSpace(u.Port()); port != "" { - return port - } - switch strings.ToLower(strings.TrimSpace(u.Scheme)) { - case "http", "ws": - return "80" - case "https", "wss": - return "443" - default: - return "" - } -} - -func isLoopbackHost(host string) bool { - host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/crates/agent-gateway/internal/server/http_origin.go b/crates/agent-gateway/internal/server/http_origin.go new file mode 100644 index 000000000..5c4fc9320 --- /dev/null +++ b/crates/agent-gateway/internal/server/http_origin.go @@ -0,0 +1,96 @@ +package server + +import ( + "net" + "net/http" + "net/url" + "strings" +) + +func originAllowed(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + requestURL := requestURLForOriginCheck(r) + if requestURL == nil { + return false + } + if sameOrigin(parsed, requestURL) { + return true + } + originHost := strings.TrimSpace(parsed.Hostname()) + requestHost := strings.TrimSpace(requestURL.Hostname()) + if originHost == "" || requestHost == "" { + return false + } + return isLoopbackHost(originHost) && isLoopbackHost(requestHost) +} + +func requestURLForOriginCheck(r *http.Request) *url.URL { + if r == nil { + return nil + } + scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) + switch scheme { + case "http", "https": + default: + return nil + } + host := strings.TrimSpace(r.Host) + if host == "" { + return nil + } + return &url.URL{Scheme: scheme, Host: host} +} + +func sameOrigin(a *url.URL, b *url.URL) bool { + if a == nil || b == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { + return false + } + return originPort(a) == originPort(b) +} + +func originPort(u *url.URL) string { + if u == nil { + return "" + } + if port := strings.TrimSpace(u.Port()); port != "" { + return port + } + switch strings.ToLower(strings.TrimSpace(u.Scheme)) { + case "http", "ws": + return "80" + case "https", "wss": + return "443" + default: + return "" + } +} + +func isLoopbackHost(host string) bool { + host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index 9a8a5a10d..3dddd44b3 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -1,8 +1,6 @@ package server import ( - "bufio" - "context" "encoding/json" "net/http" "net/http/httptest" @@ -304,70 +302,6 @@ func TestPublicHistoryShareReturnsUnavailableWhenAgentOffline(t *testing.T) { } } -func TestChatCommandRequiresCSRFHeader(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "csrf") { - t.Fatalf("body = %q, want csrf error", rec.Body.String()) - } -} - -func TestChatCommandRejectsForeignOrigin(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - req.Header.Set("Origin", "https://evil.example") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "origin") { - t.Fatalf("body = %q, want origin error", rec.Body.String()) - } -} - -func TestChatEventsRejectsForeignOrigin(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodGet, - "http://gateway.test/api/chat/events?run_id=run-1", - nil, - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Origin", "https://evil.example") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "origin") { - t.Fatalf("body = %q, want origin error", rec.Body.String()) - } -} func TestOriginAllowedRequiresStrictOriginForPublicHosts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://gateway.test:8080/api/chat/commands", nil) @@ -398,891 +332,3 @@ func TestOriginAllowedUsesForwardedProtoForSameOrigin(t *testing.T) { } } -func TestChatCommandsRejectLegacyEnvelopeShapes(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - legacyBodies := []string{ - `{"message":"hello","client_request_id":"client-1"}`, - `{"command":"chat.submit","payload":{"message":"hello","client_request_id":"client-1"}}`, - `{"type":"chat.submit","message":"hello","client_request_id":"client-1"}`, - `{"type":"chat.submit","payload":{"message":"hello","request_id":"legacy-run"}}`, - `{"type":"chat.submit","payload":null}`, - } - - for _, body := range legacyBodies { - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(body), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("body %s expected status %d, got %d body %s", body, http.StatusBadRequest, rec.Code, rec.Body.String()) - } - } -} - -func TestChatCommandsRequireClientRequestID(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "client_request_id") { - t.Fatalf("body = %q, want client_request_id error", rec.Body.String()) - } -} - -func TestChatEventsRejectLegacyRequestIDAlias(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodGet, - "http://gateway.test/api/chat/events?request_id=run-1", - nil, - ) - req.Header.Set("Authorization", "Bearer dev-token") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "run_id or conversation_id") { - t.Fatalf("body = %q, want run_id error", rec.Body.String()) - } -} - -func TestChatEventsAfterSeqParsingIsStrictNumeric(t *testing.T) { - if got := parseAfterSeq("41"); got != 41 { - t.Fatalf("parseAfterSeq numeric = %d, want 41", got) - } - if got := parseAfterSeq("run-1/41"); got != 0 { - t.Fatalf("parseAfterSeq legacy slash id = %d, want 0", got) - } -} - -func TestChatCancelCommandForwardsCancelRequest(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1","run_id":"run-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - handler.ServeHTTP(rec, req) - close(done) - }() - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - cancelReq := command.GetCancel() - if outbound.GetRequestId() != "run-1" || - command.GetType() != "chat.cancel" || - cancelReq == nil || - cancelReq.GetConversationId() != "conversation-1" { - t.Fatalf("cancel outbound id=%q payload=%#v", outbound.GetRequestId(), command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel chat request") - } - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel response") - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - var decoded map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { - t.Fatalf("decode cancel response: %v", err) - } - if decoded["accepted"] != true || decoded["run_id"] != "run-1" || decoded["conversation_id"] != "conversation-1" { - t.Fatalf("cancel response = %#v", decoded) - } -} - -func TestChatCancelRejectsLegacyRequestIDAlias(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1","request_id":"legacy-run"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - t.Fatalf("unexpected outbound cancel for rejected legacy payload: %#v", outbound) - default: - } - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } -} - -func TestChatCancelCommandFindsRunByConversation(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("run-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("run-1", "conversation-1", "started", "", "") - startedSnapshot, ok := sm.ChatRunSnapshot("run-1", "conversation-1") - if !ok { - t.Fatal("expected started run snapshot") - } - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - handler.ServeHTTP(rec, req) - close(done) - }() - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - if outbound.GetRequestId() != "run-1" { - t.Fatalf("cancel request id = %q, want run-1", outbound.GetRequestId()) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel chat request") - } - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel response") - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("run-1", "conversation-1", startedSnapshot.LatestSeq) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - select { - case event := <-ch: - if event.Control == nil || event.Control.GetType() != "cancelled" { - t.Fatalf("cancel event = %#v, want cancelled control", event) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for local cancelled event") - } -} - -func TestChatCancelByConversationIgnoresDesktopQueuedRun(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("queued-run", "conversation-1", "client-queued"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun queued created=%v err=%v", created, err) - } - sm.MarkChatRunControl("queued-run", "conversation-1", "queued_in_gui", "", "") - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - t.Fatalf("unexpected outbound cancel for desktop queued run: %#v", outbound) - default: - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - var decoded map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { - t.Fatalf("decode cancel response: %v", err) - } - if decoded["accepted"] != true || decoded["run_id"] != "" || decoded["conversation_id"] != "conversation-1" { - t.Fatalf("cancel response = %#v", decoded) - } - snapshot, ok := sm.ChatRunSnapshot("queued-run", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateDesktopQueued { - t.Fatalf("queued snapshot = %#v ok=%v, want desktop queued", snapshot, ok) - } -} - -func TestChatCancelByConversationUsesDesktopQueuedRunAfterHistoryRunning(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("queued-run", "conversation-1", "client-queued"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun queued created=%v err=%v", created, err) - } - sm.MarkChatRunControl("queued-run", "conversation-1", "queued_in_gui", "", "") - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-running-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - }, - }, - }, - }) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - handler.ServeHTTP(rec, req) - close(done) - }() - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - if outbound.GetRequestId() != "queued-run" { - t.Fatalf("cancel request id = %q, want queued-run", outbound.GetRequestId()) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel chat request") - } - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel response") - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - snapshot, ok := sm.ChatRunSnapshot("queued-run", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateCancelled { - t.Fatalf("queued snapshot = %#v ok=%v, want cancelled", snapshot, ok) - } -} - -func TestChatEditResendRejectsNegativeBaseMessageRef(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":-1,"message_index":4,"segment_id":"segment-a","message_id":"user-a","role":"user","content_hash":"fnv1a32:00000000"}}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "base_message_ref") { - t.Fatalf("body = %q, want base_message_ref error", rec.Body.String()) - } -} - -func TestChatCommandSubmitAcceptsAndSSEReplaysControl(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-1","workdir":"/workspace"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var accepted struct { - RunID string `json:"run_id"` - AcceptedSeq int64 `json:"accepted_seq"` - Conversation string `json:"conversation_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" || accepted.AcceptedSeq != 1 || accepted.Conversation != "conversation-1" { - t.Fatalf("accepted response = %#v", accepted) - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - chatReq := command.GetRequest() - if chatReq == nil { - t.Fatalf("outbound payload = %T, want ChatCommandRequest with ChatRequest", outbound.GetPayload()) - } - if command.GetType() != "chat.submit" { - t.Fatalf("chat command type = %q, want chat.submit", command.GetType()) - } - if chatReq.GetMessage() != "hello" || - chatReq.GetConversationId() != "conversation-1" || - chatReq.GetClientRequestId() != "client-1" || - chatReq.GetWorkdir() != "/workspace" { - t.Fatalf("chat request = %#v", chatReq) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat command outbound request") - } - - eventsReq, err := http.NewRequest( - http.MethodGet, - ts.URL+"/api/chat/events?run_id="+accepted.RunID+"&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - eventsReq.Header.Set("Authorization", "Bearer dev-token") - eventsResp, err := ts.Client().Do(eventsReq) - if err != nil { - t.Fatalf("get chat events: %v", err) - } - defer eventsResp.Body.Close() - if eventsResp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", eventsResp.StatusCode, http.StatusOK) - } - - event := readChatSSEEvent(t, bufio.NewReader(eventsResp.Body)) - if event["type"] != "run.accepted" || event["run_id"] != accepted.RunID { - t.Fatalf("sse event = %#v", event) - } - payload, _ := event["payload"].(map[string]any) - if payload["type"] != "accepted" || payload["seq"] != float64(1) { - t.Fatalf("sse payload = %#v", payload) - } - if _, ok := payload["request_id"]; ok { - t.Fatalf("sse payload leaked legacy request_id: %#v", payload) - } -} - -func TestChatCommandStartWatchdogFailsUndeliveredRun(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - ChatStartTimeout: 20 * time.Millisecond, - ChatRenderStartTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-watchdog-1"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var accepted struct { - RunID string `json:"run_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" { - t.Fatalf("accepted response missing run_id") - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat command outbound request") - } - - deadline := time.After(time.Second) - for { - snapshot, ok := sm.ChatRunSnapshot(accepted.RunID, "conversation-1") - if ok && snapshot.State == session.ChatRunStateFailed { - break - } - select { - case <-deadline: - t.Fatalf("timed out waiting for watchdog failure, last snapshot ok=%v value=%#v", ok, snapshot) - case <-time.After(10 * time.Millisecond): - } - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun(accepted.RunID, "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - for { - select { - case event := <-ch: - if event.Event != nil && event.Event.GetType() == gatewayv1.ChatEvent_ERROR { - if !strings.Contains(event.Event.GetData(), "Desktop backend did not accept") { - t.Fatalf("watchdog error data = %q", event.Event.GetData()) - } - return - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for watchdog error replay") - } - } -} - -func TestChatCommandDedupesClientRequestID(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - postCommand := func() map[string]any { - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-1"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var decoded map[string]any - if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - return decoded - } - - first := postCommand() - firstRunID, _ := first["run_id"].(string) - if firstRunID == "" || first["deduped"] == true { - t.Fatalf("first response = %#v", first) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - if command.GetType() != "chat.submit" || command.GetRequest() == nil { - t.Fatalf("outbound payload = %#v, want chat.submit command", command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for first chat request") - } - - second := postCommand() - if second["run_id"] != firstRunID || second["deduped"] != true { - t.Fatalf("second response = %#v, first run_id %q", second, firstRunID) - } - select { - case outbound := <-agentSession.Outbound(): - t.Fatalf("unexpected duplicate outbound request %s payload %T", outbound.GetRequestId(), outbound.GetPayload()) - case <-time.After(100 * time.Millisecond): - } -} - -func TestChatCommandSeqContinuesAcrossConversationRuns(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - postCommand := func(raw string) map[string]any { - t.Helper() - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(raw), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var decoded map[string]any - if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { - t.Fatalf("decode response: %v", err) - } - return decoded - } - - first := postCommand(`{"type":"chat.submit","payload":{"message":"first","conversation_id":"conversation-1","client_request_id":"client-submit-1"}}`) - firstRunID, _ := first["run_id"].(string) - if firstRunID == "" || first["accepted_seq"] != float64(1) { - t.Fatalf("first response = %#v, want accepted_seq 1", first) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - case <-time.After(time.Second): - t.Fatal("timed out waiting for first chat command") - } - sm.MarkChatRunControl(firstRunID, "conversation-1", "completed", "", "") - - second := postCommand(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":0,"message_index":0,"segment_id":"segment-a","message_id":"user-a","role":"user","content_hash":"fnv1a32:00000000"}}}`) - secondRunID, _ := second["run_id"].(string) - if secondRunID == "" || secondRunID == firstRunID || second["accepted_seq"] != float64(4) { - t.Fatalf("second response = %#v, want new run accepted_seq 4", second) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - if command.GetType() != "chat.edit_resend" { - t.Fatalf("second outbound command type = %q, want chat.edit_resend", command.GetType()) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for second chat command") - } -} - -func TestChatEventsReplayConversationAcrossRuns(t *testing.T) { - sm := session.NewManager() - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-submit-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - - if _, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-submit-2"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, sm)) - defer ts.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - ts.URL+"/api/chat/events?conversation_id=conversation-1&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("get chat events: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", resp.StatusCode, http.StatusOK) - } - - reader := bufio.NewReader(resp.Body) - got := make([]string, 0, 4) - for len(got) < 4 { - event := readChatSSEEvent(t, reader) - payload, _ := event["payload"].(map[string]any) - eventType, _ := payload["type"].(string) - if event["snapshot_run_id"] != "request-2" { - t.Fatalf("snapshot_run_id = %#v, want request-2", event["snapshot_run_id"]) - } - got = append(got, event["run_id"].(string)+":"+eventType) - } - cancel() - - want := []string{ - "request-1:accepted", - "request-1:user_message", - "request-1:completed", - "request-2:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation SSE replay = %#v, want %#v", got, want) - } - } -} - -func TestChatEditResendSendsSingleChatCommand(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":2,"message_index":4,"segment_id":"segment-c","message_id":"user-c","role":"user","content_hash":"fnv1a32:00000000"}}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat edit command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - - var accepted struct { - RunID string `json:"run_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" { - t.Fatalf("accepted response missing run_id") - } - - eventsReq, err := http.NewRequest( - http.MethodGet, - ts.URL+"/api/chat/events?run_id="+accepted.RunID+"&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - eventsReq.Header.Set("Authorization", "Bearer dev-token") - eventsResp, err := ts.Client().Do(eventsReq) - if err != nil { - t.Fatalf("get chat edit events: %v", err) - } - defer eventsResp.Body.Close() - if eventsResp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", eventsResp.StatusCode, http.StatusOK) - } - eventsReader := bufio.NewReader(eventsResp.Body) - if event := readChatSSEEvent(t, eventsReader); event["type"] != "run.accepted" { - t.Fatalf("first edit event = %#v, want run.accepted", event) - } - rebaseEvent := readChatSSEEvent(t, eventsReader) - if rebaseEvent["type"] != "conversation.rebased" { - t.Fatalf("second edit event = %#v, want conversation.rebased", rebaseEvent) - } - rebasePayload, _ := rebaseEvent["payload"].(map[string]any) - if rebasePayload["type"] != "rebased" || rebasePayload["reason"] != "edit_resend" { - t.Fatalf("rebase payload = %#v", rebasePayload) - } - if _, ok := rebasePayload["request_id"]; ok { - t.Fatalf("rebase payload leaked legacy request_id: %#v", rebasePayload) - } - userMessageEvent := readChatSSEEvent(t, eventsReader) - if userMessageEvent["type"] != "user.message.appended" { - t.Fatalf("third edit event = %#v, want user.message.appended", userMessageEvent) - } - userMessagePayload, _ := userMessageEvent["payload"].(map[string]any) - if userMessagePayload["type"] != "user_message" || userMessagePayload["message"] != "edited" { - t.Fatalf("user message payload = %#v", userMessagePayload) - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - chatReq := command.GetRequest() - baseRef := command.GetBaseMessageRef() - if command.GetType() != "chat.edit_resend" || chatReq == nil || baseRef == nil { - t.Fatalf("outbound payload = %#v, want chat.edit_resend command", command) - } - if outbound.GetRequestId() != accepted.RunID || - chatReq.GetMessage() != "edited" || - chatReq.GetConversationId() != "conversation-1" || - chatReq.GetClientRequestId() != "client-edit-1" || - baseRef.GetSegmentIndex() != 2 || - baseRef.GetMessageIndex() != 4 || - baseRef.GetSegmentId() != "segment-c" || - baseRef.GetMessageId() != "user-c" || - baseRef.GetRole() != "user" || - baseRef.GetContentHash() != "fnv1a32:00000000" { - t.Fatalf("chat command id=%q command=%#v", outbound.GetRequestId(), command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat edit command") - } - select { - case outbound := <-agentSession.Outbound(): - t.Fatalf("unexpected extra outbound after edit command %s payload %T", outbound.GetRequestId(), outbound.GetPayload()) - case <-time.After(100 * time.Millisecond): - } -} - -func readChatSSEEvent(t *testing.T, reader *bufio.Reader) map[string]any { - t.Helper() - var dataLine string - for { - line, err := reader.ReadString('\n') - if err != nil { - t.Fatalf("read sse line: %v", err) - } - if strings.HasPrefix(line, "data:") { - dataLine = strings.TrimSpace(strings.TrimPrefix(line, "data:")) - } - if strings.TrimSpace(line) == "" && dataLine != "" { - break - } - } - var event map[string]any - if err := json.Unmarshal([]byte(dataLine), &event); err != nil { - t.Fatalf("decode sse data %q: %v", dataLine, err) - } - return event -} diff --git a/crates/agent-gateway/internal/server/tunnel.go b/crates/agent-gateway/internal/server/tunnel_proxy.go similarity index 52% rename from crates/agent-gateway/internal/server/tunnel.go rename to crates/agent-gateway/internal/server/tunnel_proxy.go index 667201dd3..380ae69c8 100644 --- a/crates/agent-gateway/internal/server/tunnel.go +++ b/crates/agent-gateway/internal/server/tunnel_proxy.go @@ -9,7 +9,6 @@ import ( "net/url" "strings" "time" - "unicode/utf8" "github.com/google/uuid" "github.com/gorilla/websocket" @@ -17,7 +16,14 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -const tunnelBodyChunkSize = 64 * 1024 +const ( + tunnelBodyChunkSize = 64 * 1024 + tunnelRequestBodyMaxBytes = 32 * 1024 * 1024 + tunnelWebSocketDialTimeout = 30 * time.Second + tunnelDataPlaneWSReadLimit = 16 * 1024 * 1024 +) + +var errTunnelRequestBodyTooLarge = errors.New("tunnel request body too large") func publicTunnelProxy(sm *session.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -54,12 +60,13 @@ func serveTunnelHTTP( slug string, restPath string, ) { - streamID := "http-" + uuid.NewString() + streamID := "h-" + uuid.NewString() lease, err := sm.AcquireTunnel(slug, streamID) if err != nil { writeTunnelAcquireError(w, err) return } + rewrite := tunnelRewrite{slug: lease.Slug(), targetURL: lease.TargetURL()} ctx, cancel := context.WithCancel(r.Context()) completed := false @@ -68,8 +75,6 @@ func serveTunnelHTTP( if !completed { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, }) } @@ -77,28 +82,28 @@ func serveTunnelHTTP( }() start := &gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, - Method: r.Method, - Path: restPath, - Headers: filteredTunnelRequestHeaders(r.Header), + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, + TargetUrl: lease.TargetURL(), + Method: r.Method, + Path: restPath, + Headers: tunnelRequestHeaders(r, lease.Slug()), } if err := sm.SendTunnelFrameToAgent(start); err != nil { writeTunnelAcquireError(w, err) return } - bodyDone := make(chan struct{}) - go streamTunnelHTTPRequestBody(ctx, sm, lease.TunnelID(), slug, streamID, r.Body, bodyDone) + bodyResult := make(chan error, 1) + go streamTunnelHTTPRequestBody(ctx, sm, streamID, r.Body, bodyResult) responseStarted := false responseHeadersWritten := false responseStatus := http.StatusOK responseHeaders := http.Header{} - responseRewriteKind := tunnelResponseRewriteNone - var responseBody []byte + rewriteKind := tunnelResponseRewriteNone + var rewriteBuffer []byte + writeResponseHeaders := func() { if responseHeadersWritten { return @@ -107,17 +112,23 @@ func serveTunnelHTTP( w.WriteHeader(responseStatus) responseHeadersWritten = true } - writeBufferedResponse := func() { - if responseRewriteKind == tunnelResponseRewriteNone { - return - } + // flushRewriteBuffer abandons rewriting and streams what was buffered. + // Content-Length/ETag were already dropped at RESPONSE_START, so a + // mid-stream abort terminates the chunked stream instead of lying about + // the body length. + flushRewriteBuffer := func() bool { + rewriteKind = tunnelResponseRewriteNone writeResponseHeaders() - if len(responseBody) > 0 { - _, _ = w.Write(responseBody) - responseBody = nil + if len(rewriteBuffer) > 0 { + if _, err := w.Write(rewriteBuffer); err != nil { + return false + } + rewriteBuffer = nil } flushTunnelResponse(w) + return true } + for { select { case <-r.Context().Done(): @@ -125,12 +136,16 @@ func serveTunnelHTTP( case <-lease.Done(): if !responseStarted { writeTunnelError(w, http.StatusBadGateway, "tunnel stream closed") - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return - case <-bodyDone: - bodyDone = nil + case err := <-bodyResult: + bodyResult = nil + if errors.Is(err, errTunnelRequestBodyTooLarge) && !responseStarted { + writeTunnelError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } case frame := <-lease.Frames(): if frame == nil { continue @@ -141,54 +156,50 @@ func serveTunnelHTTP( continue } responseStarted = true - status := int(frame.GetStatusCode()) - if status <= 0 { - status = http.StatusOK + if status := int(frame.GetStatus()); status > 0 { + responseStatus = status } - responseStatus = status - responseHeaders = tunnelResponseHeaders(frame, lease.Tunnel()) - responseRewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) - if responseRewriteKind == tunnelResponseRewriteNone { + responseHeaders = tunnelResponseHeaders(frame, rewrite) + rewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) + if rewriteKind != tunnelResponseRewriteNone { + responseHeaders.Del("Content-Length") + responseHeaders.Del("Etag") + } else { writeResponseHeaders() flushTunnelResponse(w) } case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY: if !responseStarted { responseStarted = true - responseStatus = http.StatusOK responseHeaders = http.Header{} - responseRewriteKind = tunnelResponseRewriteNone writeResponseHeaders() } - if body := frame.GetBody(); len(body) > 0 { - if responseRewriteKind != tunnelResponseRewriteNone { - if len(responseBody)+len(body) <= tunnelRewriteBodyMaxBytes { - responseBody = append(responseBody, body...) - continue - } - responseRewriteKind = tunnelResponseRewriteNone - writeResponseHeaders() - if len(responseBody) > 0 { - if _, err := w.Write(responseBody); err != nil { - return - } - responseBody = nil - } + body := frame.GetBody() + if len(body) == 0 { + continue + } + if rewriteKind != tunnelResponseRewriteNone { + if len(rewriteBuffer)+len(body) <= tunnelRewriteBodyMaxBytes { + rewriteBuffer = append(rewriteBuffer, body...) + continue } - writeResponseHeaders() - if _, err := w.Write(body); err != nil { + if !flushRewriteBuffer() { return } - flushTunnelResponse(w) } + writeResponseHeaders() + if _, err := w.Write(body); err != nil { + return + } + flushTunnelResponse(w) case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END: - if responseRewriteKind != tunnelResponseRewriteNone { - body := responseBody - if rewritten, changed := rewriteTunnelResponseBody(body, lease.Tunnel(), responseRewriteKind); changed { + if rewriteKind != tunnelResponseRewriteNone { + body := rewriteBuffer + if rewritten, changed := rewriteTunnelResponseBody(body, rewrite, rewriteKind); changed { body = rewritten - responseHeaders.Del("Content-Length") - responseHeaders.Del("Etag") - responseHeaders.Del("ETag") + if rewriteKind == tunnelResponseRewriteHTML { + amendTunnelCSP(responseHeaders, tunnelShimScriptBody(rewrite)) + } } writeResponseHeaders() if len(body) > 0 { @@ -196,7 +207,7 @@ func serveTunnelHTTP( return } } - responseBody = nil + rewriteBuffer = nil } else if responseStarted && !responseHeadersWritten { writeResponseHeaders() } @@ -206,15 +217,15 @@ func serveTunnelHTTP( case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: if !responseStarted { writeTunnelError(w, http.StatusBadGateway, frame.GetError()) - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL: if !responseStarted { writeTunnelError(w, http.StatusServiceUnavailable, "tunnel canceled") - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return } @@ -229,89 +240,112 @@ func serveTunnelWebSocket( slug string, restPath string, ) { - streamID := "ws-" + uuid.NewString() + streamID := "w-" + uuid.NewString() lease, err := sm.AcquireTunnel(slug, streamID) if err != nil { writeTunnelAcquireError(w, err) return } + + if err := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL, + TargetUrl: lease.TargetURL(), + Method: r.Method, + Path: restPath, + Headers: tunnelWebSocketRequestHeaders(r, lease.Slug()), + }); err != nil { + lease.Release() + writeTunnelAcquireError(w, err) + return + } + + wsSubprotocol, dialErr := awaitTunnelWebSocketDial(lease) + if dialErr != nil { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: dialErr.Error(), + }) + lease.Release() + writeTunnelError(w, http.StatusBadGateway, dialErr.Error()) + return + } + upgrader := websocket.Upgrader{ CheckOrigin: func(_ *http.Request) bool { return true }, } + if wsSubprotocol != "" { + upgrader.Subprotocols = []string{wsSubprotocol} + } ws, err := upgrader.Upgrade(w, r, nil) if err != nil { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, + }) lease.Release() return } - ws.SetReadLimit(16 * 1024 * 1024) + ws.SetReadLimit(tunnelDataPlaneWSReadLimit) defer lease.Release() - handleTunnelWebSocket(ws, r, sm, lease, slug, streamID, restPath) + pumpTunnelWebSocket(ws, sm, lease, streamID) } -func handleTunnelWebSocket( +func pumpTunnelWebSocket( ws *websocket.Conn, - r *http.Request, sm *session.Manager, lease *session.TunnelStreamLease, - slug string, streamID string, - restPath string, ) { - closed := false + closeSent := false defer func() { - if !closed { + if !closeSent { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, }) } _ = ws.Close() }() - if err := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN, - Method: r.Method, - Path: restPath, - Headers: filteredTunnelRequestHeaders(r.Header), - }); err != nil { - return - } - - if !awaitTunnelWebSocketOpen(ws, lease) { - closed = true - return - } - - browserFrames := make(chan *gatewayv1.TunnelFrame, 64) + visitorFrames := make(chan *gatewayv1.TunnelFrame, 64) + visitorClose := make(chan *gatewayv1.TunnelFrame, 1) readerDone := make(chan struct{}) go func() { defer close(readerDone) for { messageType, body, err := ws.ReadMessage() if err != nil { + closeFrame := &gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, + } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + closeFrame.WsCloseCode = uint32(closeErr.Code) + closeFrame.WsCloseReason = closeErr.Text + } + visitorClose <- closeFrame return } - wireMessageType := "binary" - if messageType == websocket.TextMessage || utf8.Valid(body) { - wireMessageType = "text" + wireType := gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_BINARY + if messageType == websocket.TextMessage { + wireType = gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT } frame := &gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, Body: body, - WsMessageType: wireMessageType, + WsMessageType: wireType, } select { - case browserFrames <- frame: + case visitorFrames <- frame: case <-lease.Done(): return } @@ -321,18 +355,21 @@ func handleTunnelWebSocket( for { select { case <-lease.Done(): - closed = true + closeSent = true + return + case closeFrame := <-visitorClose: + closeSent = true + _ = sm.SendTunnelFrameToAgent(closeFrame) return case <-readerDone: - closed = true + closeSent = true _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, }) return - case frame := <-browserFrames: + case frame := <-visitorFrames: if frame != nil { _ = sm.SendTunnelFrameToAgent(frame) } @@ -342,48 +379,66 @@ func handleTunnelWebSocket( } switch frame.GetKind() { case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: - if strings.EqualFold(frame.GetWsMessageType(), "text") { - if err := ws.WriteMessage(websocket.TextMessage, frame.GetBody()); err != nil { - return - } - } else { - if err := ws.WriteMessage(websocket.BinaryMessage, frame.GetBody()); err != nil { - return - } + messageType := websocket.BinaryMessage + if frame.GetWsMessageType() == gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT { + messageType = websocket.TextMessage } - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, - gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + if err := ws.WriteMessage(messageType, frame.GetBody()); err != nil { + return + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: + closeSent = true + code := int(frame.GetWsCloseCode()) + if code == 0 { + code = websocket.CloseNormalClosure + } + deadline := time.Now().Add(time.Second) + _ = ws.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(code, frame.GetWsCloseReason()), + deadline, + ) + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: - closed = true + closeSent = true + deadline := time.Now().Add(time.Second) + _ = ws.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, ""), + deadline, + ) return } } } } -func awaitTunnelWebSocketOpen(ws *websocket.Conn, lease *session.TunnelStreamLease) bool { - timer := time.NewTimer(30 * time.Second) +func awaitTunnelWebSocketDial(lease *session.TunnelStreamLease) (string, error) { + timer := time.NewTimer(tunnelWebSocketDialTimeout) defer timer.Stop() for { select { case <-timer.C: - return false + return "", errors.New("local tunnel websocket dial timed out") case <-lease.Done(): - return false + return "", errors.New("tunnel stream closed before websocket dial completed") case frame := <-lease.Frames(): if frame == nil { continue } switch frame.GetKind() { - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN: - return true - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_OK: + return strings.TrimSpace(frame.GetWsSubprotocol()), nil + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_ERROR, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: - if frame.GetError() != "" { - _ = ws.WriteMessage(websocket.TextMessage, []byte(frame.GetError())) + message := strings.TrimSpace(frame.GetError()) + if message == "" { + message = "local tunnel websocket dial failed" } - return false + return "", errors.New(message) } } } @@ -392,45 +447,53 @@ func awaitTunnelWebSocketOpen(ws *websocket.Conn, lease *session.TunnelStreamLea func streamTunnelHTTPRequestBody( ctx context.Context, sm *session.Manager, - tunnelID string, - slug string, streamID string, body io.ReadCloser, - done chan<- struct{}, + result chan<- error, ) { - defer close(done) + var resultErr error + defer func() { + result <- resultErr + }() defer body.Close() buffer := make([]byte, tunnelBodyChunkSize) + sent := 0 for { n, err := body.Read(buffer) if n > 0 { + sent += n + if sent > tunnelRequestBodyMaxBytes { + resultErr = errTunnelRequestBodyTooLarge + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: errTunnelRequestBodyTooLarge.Error(), + }) + return + } chunk := make([]byte, n) copy(chunk, buffer[:n]) if sendErr := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY, Body: chunk, }); sendErr != nil { + resultErr = sendErr return } } if errors.Is(err, io.EOF) { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END, }) return } if err != nil { + resultErr = err _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, Error: err.Error(), }) @@ -438,6 +501,7 @@ func streamTunnelHTTPRequestBody( } select { case <-ctx.Done(): + resultErr = ctx.Err() return default: } @@ -499,27 +563,21 @@ func isWebSocketUpgrade(r *http.Request) bool { strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") } -func filteredTunnelRequestHeaders(headers http.Header) []*gatewayv1.TunnelHeader { - return filteredTunnelHeaders(headers, true) +func tunnelRequestHeaders(r *http.Request, slug string) []*gatewayv1.TunnelHeader { + headers := filteredTunnelRequestHeaders(r.Header, false) + return appendTunnelForwardedHeaders(headers, r, slug) } -func filteredTunnelResponseHeaders(headers []*gatewayv1.TunnelHeader) http.Header { - out := http.Header{} - for _, header := range headers { - name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) - if name == "" || shouldDropTunnelHeader(name, false) { - continue - } - out.Add(name, header.GetValue()) - } - return out +func tunnelWebSocketRequestHeaders(r *http.Request, slug string) []*gatewayv1.TunnelHeader { + headers := filteredTunnelRequestHeaders(r.Header, true) + return appendTunnelForwardedHeaders(headers, r, slug) } -func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.TunnelHeader { +func filteredTunnelRequestHeaders(headers http.Header, websocketUpgrade bool) []*gatewayv1.TunnelHeader { out := make([]*gatewayv1.TunnelHeader, 0, len(headers)) for name, values := range headers { canonical := http.CanonicalHeaderKey(strings.TrimSpace(name)) - if canonical == "" || shouldDropTunnelHeader(canonical, request) { + if canonical == "" || shouldDropTunnelRequestHeader(canonical, websocketUpgrade) { continue } for _, value := range values { @@ -532,7 +590,36 @@ func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.Tunne return out } -func shouldDropTunnelHeader(name string, request bool) bool { +func shouldDropTunnelRequestHeader(name string, websocketUpgrade bool) bool { + lower := strings.ToLower(name) + // Visitor-supplied forwarding headers are stripped so the local service + // only ever sees the gateway's own X-Forwarded-* values. + if strings.HasPrefix(lower, "x-forwarded-") || lower == "forwarded" { + return true + } + switch lower { + case "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host": + return true + } + if websocketUpgrade { + switch lower { + case "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-accept": + return true + } + } + return false +} + +func shouldDropTunnelResponseHeader(name string) bool { switch strings.ToLower(name) { case "connection", "keep-alive", @@ -544,38 +631,51 @@ func shouldDropTunnelHeader(name string, request bool) bool { "transfer-encoding", "upgrade": return true - case "host": - return request default: return false } } -func writeTunnelResponseHeaders( - w http.ResponseWriter, - frame *gatewayv1.TunnelFrame, - tunnel *gatewayv1.TunnelSummary, -) { - writeTunnelHTTPHeaders(w, tunnelResponseHeaders(frame, tunnel)) +func appendTunnelForwardedHeaders( + headers []*gatewayv1.TunnelHeader, + r *http.Request, + slug string, +) []*gatewayv1.TunnelHeader { + if r == nil { + return headers + } + proto := "http" + if r.TLS != nil { + proto = "https" + } + headers = append(headers, + &gatewayv1.TunnelHeader{Name: "X-Forwarded-Host", Value: r.Host}, + &gatewayv1.TunnelHeader{Name: "X-Forwarded-Proto", Value: proto}, + ) + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Origin", Value: origin}) + } + if slug = strings.TrimSpace(slug); slug != "" { + headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Prefix", Value: "/t/" + slug}) + } + return headers } -func tunnelResponseHeaders( - frame *gatewayv1.TunnelFrame, - tunnel *gatewayv1.TunnelSummary, -) http.Header { - headers := filteredTunnelResponseHeaders(frame.GetHeaders()) - for name, values := range headers { - rewritten := make([]string, 0, len(values)) - for _, value := range values { - if strings.EqualFold(name, "Location") { - value = rewriteTunnelLocation(value, tunnel) - } - if strings.EqualFold(name, "Set-Cookie") { - value = rewriteTunnelSetCookiePath(value, tunnel) - } - rewritten = append(rewritten, value) +func tunnelResponseHeaders(frame *gatewayv1.TunnelFrame, rw tunnelRewrite) http.Header { + headers := http.Header{} + for _, header := range frame.GetHeaders() { + name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) + if name == "" || shouldDropTunnelResponseHeader(name) { + continue } - headers[name] = rewritten + value := header.GetValue() + if strings.EqualFold(name, "Location") { + value = rewriteTunnelLocation(value, rw) + } + if strings.EqualFold(name, "Set-Cookie") { + value = rewriteTunnelSetCookiePath(value, rw) + } + headers.Add(name, value) } return headers } @@ -594,15 +694,15 @@ func flushTunnelResponse(w http.ResponseWriter) { } } -func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil { +func rewriteTunnelLocation(value string, rw tunnelRewrite) string { + publicPrefix := rw.publicPrefix() + if publicPrefix == "" { return value } - target, err := url.Parse(tunnel.GetTargetUrl()) + target, err := rw.parseTarget() if err != nil || target.Host == "" { return value } - publicPrefix := "/t/" + tunnel.GetSlug() parsed, err := url.Parse(value) if err != nil { return value @@ -612,40 +712,23 @@ func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string return value } path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) - if path == "" { - path = "/" - } - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - if parsed.Fragment != "" { - path += "#" + parsed.EscapedFragment() - } - return publicPrefix + path + return publicPrefix + appendTunnelURLQueryAndFragment(pathOrRoot(path), parsed) } if strings.HasPrefix(value, "/") { path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) - if path == "" { - path = "/" - } - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - if parsed.Fragment != "" { - path += "#" + parsed.EscapedFragment() - } - return publicPrefix + path + return publicPrefix + appendTunnelURLQueryAndFragment(pathOrRoot(path), parsed) } return value } -func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil || tunnel.GetSlug() == "" { +func rewriteTunnelSetCookiePath(value string, rw tunnelRewrite) string { + slug := strings.TrimSpace(rw.slug) + if slug == "" { return value } parts := strings.Split(value, ";") targetBasePath := "/" - if target, err := url.Parse(tunnel.GetTargetUrl()); err == nil { + if target, err := rw.parseTarget(); err == nil { targetBasePath = target.EscapedPath() } for index, part := range parts { @@ -665,7 +748,7 @@ func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) s if leading := len(part) - len(strings.TrimLeft(part, " \t")); leading > 0 { prefix = part[:leading] } - parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, tunnel.GetSlug(), rest) + parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, slug, rest) } return strings.Join(parts, ";") } @@ -695,30 +778,3 @@ func normalizeTunnelPath(value string) string { } return value } - -func publicBaseURLFromHTTPRequest(r *http.Request) string { - if r == nil { - return "" - } - scheme := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - host := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Host")) - if host == "" { - host = r.Host - } - if host == "" { - return "" - } - return scheme + "://" + host -} - -func forwardedHeaderFirst(value string) string { - first := strings.TrimSpace(strings.Split(value, ",")[0]) - return strings.TrimSpace(first) -} diff --git a/crates/agent-gateway/internal/server/tunnel_proxy_test.go b/crates/agent-gateway/internal/server/tunnel_proxy_test.go new file mode 100644 index 000000000..8a2666b71 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_proxy_test.go @@ -0,0 +1,207 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" +) + +func TestParseTunnelPublicPath(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + slug string + rest string + ok bool + }{ + {"/t/abc/", "abc", "/", true}, + {"/t/abc/app/index.html", "abc", "/app/index.html", true}, + {"/t/abc", "abc", "/", true}, + {"/t/", "", "", false}, + {"/other", "", "", false}, + } + for _, tt := range tests { + slug, rest, ok := parseTunnelPublicPath(tt.raw) + if slug != tt.slug || rest != tt.rest || ok != tt.ok { + t.Fatalf("parseTunnelPublicPath(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.raw, slug, rest, ok, tt.slug, tt.rest, tt.ok) + } + } + + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/abc"); !ok || slug != "abc" { + t.Fatalf("no-trailing-slash parse = (%q, %v)", slug, ok) + } + if _, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/abc/x"); ok { + t.Fatal("nested path must not match the redirect case") + } +} + +func TestTunnelRequestHeadersStripForwardedAndHopByHop(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "http://public.example/t/abc/", nil) + req.Header.Set("X-Forwarded-Host", "evil.example") + req.Header.Set("X-Forwarded-For", "1.2.3.4") + req.Header.Set("Forwarded", "for=1.2.3.4") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Transfer-Encoding", "chunked") + req.Header.Set("Accept", "text/html") + req.Header.Set("Origin", "https://public.example") + + headers := tunnelRequestHeaders(req, "abc") + byName := map[string][]string{} + for _, header := range headers { + byName[header.GetName()] = append(byName[header.GetName()], header.GetValue()) + } + + if got := byName["X-Forwarded-Host"]; len(got) != 1 || got[0] != "public.example" { + t.Fatalf("X-Forwarded-Host = %v, want the gateway-derived host only", got) + } + for _, banned := range []string{"X-Forwarded-For", "Forwarded", "Connection", "Transfer-Encoding", "Host"} { + if _, present := byName[banned]; present { + t.Fatalf("header %q must be stripped", banned) + } + } + if got := byName["X-Forwarded-Prefix"]; len(got) != 1 || got[0] != "/t/abc" { + t.Fatalf("X-Forwarded-Prefix = %v", got) + } + if got := byName["X-Forwarded-Origin"]; len(got) != 1 || got[0] != "https://public.example" { + t.Fatalf("X-Forwarded-Origin = %v", got) + } + if got := byName["Accept"]; len(got) != 1 || got[0] != "text/html" { + t.Fatalf("Accept = %v, want passthrough", got) + } +} + +func TestTunnelWebSocketRequestHeadersStripSecWebSocket(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "http://public.example/t/abc/ws", nil) + req.Header.Set("Sec-Websocket-Key", "k") + req.Header.Set("Sec-Websocket-Version", "13") + req.Header.Set("Sec-Websocket-Protocol", "graphql-ws") + + headers := tunnelWebSocketRequestHeaders(req, "abc") + byName := map[string]string{} + for _, header := range headers { + byName[header.GetName()] = header.GetValue() + } + if _, present := byName["Sec-Websocket-Key"]; present { + t.Fatal("Sec-WebSocket-Key must be stripped") + } + if _, present := byName["Sec-Websocket-Version"]; present { + t.Fatal("Sec-WebSocket-Version must be stripped") + } + if got := byName["Sec-Websocket-Protocol"]; got != "graphql-ws" { + t.Fatalf("Sec-WebSocket-Protocol = %q, want passthrough", got) + } +} + +func TestWriteTunnelAcquireErrorStatusMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + status int + }{ + {session.ErrTunnelNotFound, http.StatusNotFound}, + {session.ErrTunnelExpired, http.StatusNotFound}, + {session.ErrAgentOffline, http.StatusServiceUnavailable}, + {session.ErrTunnelOverLimit, http.StatusTooManyRequests}, + } + for _, tt := range tests { + recorder := httptest.NewRecorder() + writeTunnelAcquireError(recorder, tt.err) + if recorder.Code != tt.status { + t.Fatalf("status for %v = %d, want %d", tt.err, recorder.Code, tt.status) + } + } +} + +func TestTunnelResponseHeadersRewriteLocationAndCookies(t *testing.T) { + t.Parallel() + + rw := tunnelRewrite{slug: "abc", targetURL: "http://localhost:3000"} + frame := &gatewayv1.TunnelFrame{ + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Location", Value: "http://localhost:3000/login?next=%2F"}, + {Name: "Set-Cookie", Value: "sid=1; Path=/; HttpOnly"}, + {Name: "Transfer-Encoding", Value: "chunked"}, + {Name: "Content-Type", Value: "text/html"}, + }, + } + headers := tunnelResponseHeaders(frame, rw) + if got := headers.Get("Location"); got != "/t/abc/login?next=%2F" { + t.Fatalf("Location = %q", got) + } + if got := headers.Get("Set-Cookie"); !strings.Contains(got, "Path=/t/abc/") { + t.Fatalf("Set-Cookie = %q, want rewritten path", got) + } + if headers.Get("Transfer-Encoding") != "" { + t.Fatal("hop-by-hop response header must be dropped") + } + if headers.Get("Content-Type") != "text/html" { + t.Fatal("Content-Type must pass through") + } +} + +func TestAmendTunnelCSPHashAmendable(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + policy := headers.Get("Content-Security-Policy") + if !strings.Contains(policy, "script-src 'self' 'sha256-") { + t.Fatalf("policy = %q, want sha256 appended to script-src", policy) + } + if strings.Contains(strings.TrimPrefix(policy, "default-src 'self' 'sha256-"), "default-src 'self' 'sha256-") { + t.Fatalf("policy = %q, default-src must stay untouched when script-src exists", policy) + } +} + +func TestAmendTunnelCSPDefaultSrcFallback(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "default-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + if policy := headers.Get("Content-Security-Policy"); !strings.Contains(policy, "default-src 'self' 'sha256-") { + t.Fatalf("policy = %q, want sha256 appended to default-src", policy) + } +} + +func TestAmendTunnelCSPNonceStripped(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "script-src 'nonce-abc123'") + headers.Set("Content-Security-Policy-Report-Only", "script-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + if headers.Get("Content-Security-Policy") != "" { + t.Fatal("nonce policy must be stripped") + } + if headers.Get("Content-Security-Policy-Report-Only") != "" { + t.Fatal("report-only policy must be stripped alongside") + } + if headers.Get("X-Liveagent-Tunnel-Csp") != "stripped" { + t.Fatal("stripped marker header missing") + } +} + +func TestAmendTunnelCSPUnsafeInlineLeftAlone(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "script-src 'self' 'unsafe-inline'") + amendTunnelCSP(headers, "console.log(1)") + policy := headers.Get("Content-Security-Policy") + if strings.Contains(policy, "sha256-") { + t.Fatalf("policy = %q; adding a hash would re-disable 'unsafe-inline'", policy) + } +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go index 3f4729517..8a7dd8e2e 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -1,6 +1,9 @@ package server import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" "io" "mime" "net/http" @@ -11,8 +14,6 @@ import ( "github.com/tdewolff/parse/v2" "github.com/tdewolff/parse/v2/css" "golang.org/x/net/html" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024 @@ -23,7 +24,6 @@ const ( tunnelResponseRewriteNone tunnelResponseRewriteKind = iota tunnelResponseRewriteHTML tunnelResponseRewriteCSS - tunnelResponseRewriteJavaScript // kept for legacy tests/helpers; JS bodies are not rewritten. ) func tunnelResponseRewriteKindFor( @@ -58,13 +58,6 @@ func tunnelResponseRewriteKindFor( return tunnelResponseRewriteHTML case mediaType == "text/css": return tunnelResponseRewriteCSS - case mediaType == "text/javascript", - mediaType == "application/javascript", - mediaType == "application/x-javascript", - mediaType == "text/ecmascript", - mediaType == "application/ecmascript", - strings.HasSuffix(mediaType, "+javascript"): - return tunnelResponseRewriteNone default: return tunnelResponseRewriteNone } @@ -72,10 +65,10 @@ func tunnelResponseRewriteKindFor( func rewriteTunnelResponseBody( body []byte, - tunnel *gatewayv1.TunnelSummary, + rw tunnelRewrite, kind tunnelResponseRewriteKind, ) ([]byte, bool) { - if len(body) == 0 || kind == tunnelResponseRewriteNone || tunnelPublicPathPrefix(tunnel) == "" { + if len(body) == 0 || kind == tunnelResponseRewriteNone || rw.publicPrefix() == "" { return body, false } if !utf8.Valid(body) { @@ -86,11 +79,9 @@ func rewriteTunnelResponseBody( rewritten := original switch kind { case tunnelResponseRewriteHTML: - rewritten = rewriteTunnelHTMLBody(rewritten, tunnel) + rewritten = rewriteTunnelHTMLBody(rewritten, rw) case tunnelResponseRewriteCSS: - rewritten = rewriteTunnelCSSBody(rewritten, tunnel) - case tunnelResponseRewriteJavaScript: - rewritten = rewriteTunnelJavaScriptBody(rewritten, tunnel) + rewritten = rewriteTunnelCSSBody(rewritten, rw) } if rewritten == original { return body, false @@ -98,10 +89,12 @@ func rewriteTunnelResponseBody( return []byte(rewritten), true } -func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string { +func rewriteTunnelHTMLBody(input string, rw tunnelRewrite) string { tokenizer := html.NewTokenizer(strings.NewReader(input)) var builder strings.Builder changed := false + injected := false + shim := tunnelRuntimeBootstrapScript(rw) for { tokenType := tokenizer.Next() @@ -119,19 +112,25 @@ func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string } token := tokenizer.Token() + tagName := strings.ToLower(strings.TrimSpace(token.Data)) + if !injected && shim != "" && tagName == "script" { + builder.WriteString(shim) + injected = true + changed = true + } tokenChanged := false for index := range token.Attr { attr := &token.Attr[index] key := strings.ToLower(strings.TrimSpace(attr.Key)) switch { case isTunnelHTMLURLAttribute(key): - rewritten := rewriteTunnelBodyURL(attr.Val, tunnel) + rewritten := rewriteTunnelBodyURL(attr.Val, rw) if rewritten != attr.Val { attr.Val = rewritten tokenChanged = true } case key == "style": - rewritten := rewriteTunnelCSSBody(attr.Val, tunnel) + rewritten := rewriteTunnelCSSBody(attr.Val, rw) if rewritten != attr.Val { attr.Val = rewritten tokenChanged = true @@ -144,15 +143,23 @@ func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string } else { builder.WriteString(raw) } + if !injected && shim != "" && tagName == "head" { + builder.WriteString(shim) + injected = true + changed = true + } } + if !injected && shim != "" { + return shim + builder.String() + } if !changed { return input } return builder.String() } -func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string { +func rewriteTunnelCSSBody(input string, rw tunnelRewrite) string { lexer := css.NewLexer(parse.NewInputString(input)) var builder strings.Builder changed := false @@ -168,7 +175,7 @@ func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string token := string(data) if tokenType == css.URLToken { - if rewritten, ok := rewriteTunnelCSSURLToken(token, tunnel); ok { + if rewritten, ok := rewriteTunnelCSSURLToken(token, rw); ok { builder.WriteString(rewritten) changed = true continue @@ -183,10 +190,6 @@ func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string return builder.String() } -func rewriteTunnelJavaScriptBody(input string, tunnel *gatewayv1.TunnelSummary) string { - return input -} - func isTunnelHTMLURLAttribute(key string) bool { switch key { case "href", "src", "action", "poster", "data", "formaction", "xlink:href": @@ -196,7 +199,44 @@ func isTunnelHTMLURLAttribute(key string) bool { } } -func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (string, bool) { +func tunnelRuntimeBootstrapScript(rw tunnelRewrite) string { + body := tunnelShimScriptBody(rw) + if body == "" { + return "" + } + return `` +} + +// tunnelShimScriptBody is the raw JS between the shim's script tags; CSP +// hash amendment must digest exactly this string. +func tunnelShimScriptBody(rw tunnelRewrite) string { + prefix := rw.publicPrefix() + if prefix == "" { + return "" + } + config, err := json.Marshal(map[string]string{ + "basePath": prefix, + }) + if err != nil { + return "" + } + return `(function(config){` + + `if(window.__LIVEAGENT_TUNNEL__&&window.__LIVEAGENT_TUNNEL__.installed)return;` + + `var base=String(config.basePath||"").replace(/\/+$/,"");` + + `window.__LIVEAGENT_TUNNEL__={basePath:base,installed:true};` + + `function rw(input){if(input==null||!base)return input;var raw=input instanceof URL?input.href:String(input);var u;try{u=new URL(raw,location.href)}catch(_){return input}` + + `if(u.host!==location.host||!/^(http:|https:|ws:|wss:)$/i.test(u.protocol))return input;` + + `if(u.pathname===base||u.pathname.indexOf(base+"/")===0)return u.href;` + + `u.pathname=base+(u.pathname==="/"?"/":u.pathname);return u.href}` + + `function rwWs(input){var out=rw(input);try{var u=new URL(String(out),location.href);if(u.protocol==="http:")u.protocol="ws:";if(u.protocol==="https:")u.protocol="wss:";return u.href}catch(_){return out}}` + + `if(window.WebSocket){var NativeWebSocket=window.WebSocket;window.WebSocket=function(url,protocols){return new NativeWebSocket(rwWs(url),protocols)};window.WebSocket.prototype=NativeWebSocket.prototype;["CONNECTING","OPEN","CLOSING","CLOSED"].forEach(function(k){window.WebSocket[k]=NativeWebSocket[k]})}` + + `if(window.EventSource){var NativeEventSource=window.EventSource;window.EventSource=function(url,options){return new NativeEventSource(rw(url),options)};window.EventSource.prototype=NativeEventSource.prototype}` + + `if(window.fetch){var nativeFetch=window.fetch.bind(window);window.fetch=function(input,init){if(input instanceof Request)return nativeFetch(new Request(rw(input.url),input),init);return nativeFetch(rw(input),init)}}` + + `if(window.XMLHttpRequest){var open=window.XMLHttpRequest.prototype.open;window.XMLHttpRequest.prototype.open=function(method,url){arguments[1]=rw(url);return open.apply(this,arguments)}}` + + `})(` + string(config) + `);` +} + +func rewriteTunnelCSSURLToken(token string, rw tunnelRewrite) (string, bool) { openIndex := strings.Index(token, "(") closeIndex := strings.LastIndex(token, ")") if openIndex < 0 || closeIndex < openIndex { @@ -224,7 +264,7 @@ func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (st value = value[1 : len(value)-1] } - rewritten := rewriteTunnelBodyURL(value, tunnel) + rewritten := rewriteTunnelBodyURL(value, rw) if rewritten == value { return token, false } @@ -237,8 +277,8 @@ func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (st return before + leading + rewritten + trailing + after, true } -func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string { - prefix := tunnelPublicPathPrefix(tunnel) +func rewriteTunnelBodyURL(value string, rw tunnelRewrite) string { + prefix := rw.publicPrefix() if prefix == "" { return value } @@ -253,7 +293,7 @@ func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string if err != nil { return value } - target, targetErr := url.Parse(tunnel.GetTargetUrl()) + target, targetErr := rw.parseTarget() if parsed.IsAbs() { if targetErr != nil || target.Host == "" { return value @@ -279,17 +319,90 @@ func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) } -func tunnelPublicPathPrefix(tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil { - return "" - } - slug := strings.TrimSpace(tunnel.GetSlug()) +// tunnelRewrite carries the two facts body/header rewriting needs: which +// public prefix the tunnel is mounted under and which local target it fronts. +type tunnelRewrite struct { + slug string + targetURL string +} + +func (rw tunnelRewrite) publicPrefix() string { + slug := strings.TrimSpace(rw.slug) if slug == "" { return "" } return "/t/" + slug } +func (rw tunnelRewrite) parseTarget() (*url.URL, error) { + return url.Parse(strings.TrimSpace(rw.targetURL)) +} + +// amendTunnelCSP makes the injected shim executable under the response's +// Content-Security-Policy. Hash-amendable policies get the shim's sha256; +// nonce/strict-dynamic policies cannot be amended without weakening them, so +// they are stripped with an explicit marker header instead. +func amendTunnelCSP(headers http.Header, shimScriptBody string) { + policies := headers.Values("Content-Security-Policy") + if len(policies) == 0 || strings.TrimSpace(shimScriptBody) == "" { + return + } + digest := sha256.Sum256([]byte(shimScriptBody)) + hash := "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'" + + amended := make([]string, 0, len(policies)) + for _, policy := range policies { + lower := strings.ToLower(policy) + if strings.Contains(lower, "'nonce-") || strings.Contains(lower, "'strict-dynamic'") { + headers.Del("Content-Security-Policy") + headers.Del("Content-Security-Policy-Report-Only") + headers.Set("X-Liveagent-Tunnel-Csp", "stripped") + return + } + amended = append(amended, amendTunnelCSPPolicy(policy, hash)) + } + headers.Del("Content-Security-Policy") + for _, policy := range amended { + headers.Add("Content-Security-Policy", policy) + } +} + +func amendTunnelCSPPolicy(policy string, hash string) string { + directives := strings.Split(policy, ";") + scriptIndexes := make([]int, 0, 2) + defaultIndex := -1 + for index, directive := range directives { + fields := strings.Fields(directive) + if len(fields) == 0 { + continue + } + name := strings.ToLower(fields[0]) + switch name { + case "script-src", "script-src-elem": + scriptIndexes = append(scriptIndexes, index) + case "default-src": + defaultIndex = index + } + } + targets := scriptIndexes + if len(targets) == 0 { + if defaultIndex < 0 { + return policy // no script restriction to satisfy + } + targets = []int{defaultIndex} + } + for _, index := range targets { + lower := strings.ToLower(directives[index]) + // A hash would re-disable 'unsafe-inline' on policies that rely on it. + if strings.Contains(lower, "'unsafe-inline'") && + !strings.Contains(lower, "'sha") && !strings.Contains(lower, "'nonce-") { + continue + } + directives[index] = strings.TrimRight(directives[index], " ") + " " + hash + } + return strings.Join(directives, ";") +} + func pathOrRoot(path string) string { if strings.TrimSpace(path) == "" { return "/" diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go index 9fb7c7ce3..4594171c3 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) func TestTunnelResponseRewriteKindFor(t *testing.T) { @@ -109,6 +108,7 @@ func TestRewriteTunnelHTMLBodyPrefixesRootRelativeAttributes(t *testing.T) { output := string(body) assertContains(t, output, `href="/t/test-slug/styles.css"`) + assertContains(t, output, `data-liveagent-tunnel-shim`) assertContains(t, output, `src="/t/test-slug/app.js"`) assertContains(t, output, `action="/t/test-slug/api/messages"`) assertContains(t, output, `href="/t/test-slug/api/health?check=1#ready"`) @@ -123,9 +123,9 @@ func TestRewriteTunnelHTMLBodyPrefixesRootRelativeAttributes(t *testing.T) { func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { t.Parallel() - tunnel := &gatewayv1.TunnelSummary{ - Slug: "base-slug", - TargetUrl: "http://127.0.0.1:3100/app", + tunnel := tunnelRewrite{ + slug: "base-slug", + targetURL: "http://127.0.0.1:3100/app", } input := strings.Join([]string{ ``, @@ -144,16 +144,6 @@ func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { assertContains(t, output, `href="/t/base-slug/api/health"`) assertNotContains(t, output, `/t/base-slug/app/`) - jsBody, changed := rewriteTunnelResponseBody( - []byte(`fetch("/app/api/health?check=1#ready")`), - tunnel, - tunnelResponseRewriteJavaScript, - ) - if changed { - t.Fatal("rewriteTunnelResponseBody() reported an unsafe JavaScript change") - } - assertContains(t, string(jsBody), `fetch("/app/api/health?check=1#ready")`) - cssBody, changed := rewriteTunnelResponseBody( []byte(`body { background: url(/app/images/bg.png); }`), tunnel, @@ -165,7 +155,7 @@ func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { assertContains(t, string(cssBody), `url(/t/base-slug/images/bg.png)`) } -func TestRewriteTunnelJavaScriptBodyDoesNotRewriteStrings(t *testing.T) { +func TestRewriteTunnelJavaScriptBodyIsNotRewritten(t *testing.T) { t.Parallel() tunnel := tunnelRewriteTestSummary() @@ -178,7 +168,7 @@ func TestRewriteTunnelJavaScriptBodyDoesNotRewriteStrings(t *testing.T) { `const already = "/t/test-slug/api/health"`, }, "\n") - body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteJavaScript) + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteNone) if changed { t.Fatal("rewriteTunnelResponseBody() reported an unsafe JavaScript change") } @@ -210,9 +200,34 @@ func TestRewriteTunnelHTMLBodyUsesHTMLParsingBoundaries(t *testing.T) { assertContains(t, output, `style="background: url('/t/test-slug/images/bg.png')"`) assertContains(t, output, ``) + assertContains(t, output, `data-liveagent-tunnel-shim`) assertNotContains(t, output, `/t/test-slug/api/not-real`) } +func TestRewriteTunnelHTMLBodyInjectsRuntimeShimBeforeFirstScript(t *testing.T) { + t.Parallel() + + body, changed := rewriteTunnelResponseBody( + []byte(``), + tunnelRewriteTestSummary(), + tunnelResponseRewriteHTML, + ) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not inject runtime shim") + } + output := string(body) + shimIndex := strings.Index(output, `data-liveagent-tunnel-shim`) + appIndex := strings.Index(output, `new WebSocket`) + if shimIndex < 0 || appIndex < 0 || shimIndex > appIndex { + t.Fatalf("runtime shim was not injected before app script:\n%s", output) + } + assertContains(t, output, `"basePath":"/t/test-slug"`) + assertContains(t, output, `window.WebSocket=function`) + assertContains(t, output, `window.fetch=function`) + assertContains(t, output, `window.EventSource=function`) + assertContains(t, output, `XMLHttpRequest.prototype.open`) +} + func TestRewriteTunnelCSSBodyPrefixesRootRelativeURLs(t *testing.T) { t.Parallel() @@ -288,10 +303,10 @@ func TestRewriteTunnelLocationPreservesQueryAndFragment(t *testing.T) { } } -func tunnelRewriteTestSummary() *gatewayv1.TunnelSummary { - return &gatewayv1.TunnelSummary{ - Slug: "test-slug", - TargetUrl: "http://127.0.0.1:3100", +func tunnelRewriteTestSummary() tunnelRewrite { + return tunnelRewrite{ + slug: "test-slug", + targetURL: "http://127.0.0.1:3100", } } diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 6ef286b55..04ebcd575 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -89,31 +89,52 @@ type websocketGitRequestPayload struct { Args json.RawMessage `json:"args,omitempty"` } +const ( + websocketWriteQueueDefault = 512 + websocketMaxWriteRetries = 2 + websocketRetryBackoff = 100 * time.Millisecond +) + type websocketConnection struct { cfg *config.Config sm *session.Manager - conn *websocket.Conn - req *http.Request + conn *websocket.Conn + req *http.Request + writeMu sync.Mutex + writeTimeout time.Duration + outbox chan websocketEnvelope - writer *websocketConnectionWriter closeOnce sync.Once done chan struct{} authorized bool - historyEvents <-chan *gatewayv1.HistorySyncEvent - historyEventsCleanup func() - settingsEvents <-chan *gatewayv1.SettingsSyncEvent - settingsEventsCleanup func() - terminalEvents <-chan *gatewayv1.TerminalEvent - terminalEventsCleanup func() - sftpEvents <-chan *gatewayv1.SftpEvent - sftpEventsCleanup func() - chatQueueEvents <-chan *gatewayv1.ChatQueueEvent - chatQueueEventsCleanup func() - heartbeatOnce sync.Once + lastInboundAt time.Time + lastInboundMu sync.Mutex + + historyEvents <-chan *gatewayv1.HistorySyncEvent + historyEventsCleanup func() + settingsEvents <-chan *gatewayv1.SettingsSyncEvent + settingsEventsCleanup func() + terminalEvents <-chan *gatewayv1.TerminalEvent + terminalEventsCleanup func() + sftpEvents <-chan *gatewayv1.SftpEvent + sftpEventsCleanup func() + chatQueueEvents <-chan *gatewayv1.ChatQueueEvent + chatQueueEventsCleanup func() + chatActivityEvents <-chan session.ConversationActivityEvent + chatActivityEventsCleanup func() + tunnelStateEvents <-chan *gatewayv1.TunnelStateSnapshot + tunnelStateEventsCleanup func() + heartbeatOnce sync.Once terminalInterest *websocketTerminalInterestTracker + + chatStreamsMu sync.Mutex + chatStreams map[string]*chatStreamSubscription + + workspaceSubsMu sync.Mutex + workspaceSubs map[string]*workspaceActivitySubscription } const maxHistoryListLimit = 200 @@ -133,12 +154,17 @@ func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { } conn.SetReadLimit(webSocketReadLimit(cfg)) + queueSize := websocketWriteQueueDefault + if cfg.WebSocketWriteQueueSize > 0 { + queueSize = cfg.WebSocketWriteQueueSize + } state := &websocketConnection{ cfg: cfg, sm: sm, conn: conn, req: r, - writer: newWebsocketConnectionWriter(conn, cfg.WebSocketWriteTimeout), + writeTimeout: cfg.WebSocketWriteTimeout, + outbox: make(chan websocketEnvelope, queueSize), done: make(chan struct{}), terminalInterest: newWebsocketTerminalInterestTracker(), } @@ -164,6 +190,10 @@ func (c *websocketConnection) serve() { return } + // Any inbound frame proves the client is alive — heartbeat pongs are + // not the only liveness evidence. + c.touchInboundActivity() + req.ID = strings.TrimSpace(req.ID) req.Type = strings.TrimSpace(req.Type) if req.Type == "pong" { @@ -188,6 +218,17 @@ func (c *websocketConnection) serve() { continue } + // Subscription lifecycle must keep the client's frame order: a + // re-subscribe emits [unsubscribe, subscribe] back to back, and + // concurrent dispatch could let the stale unsubscribe cancel the fresh + // subscription. These handlers are lock-only and non-blocking, so they + // run inline on the read loop. + if req.Type == "chat.subscribe" || req.Type == "chat.unsubscribe" || + req.Type == "workspace.subscribe" || req.Type == "workspace.unsubscribe" { + c.dispatch(req) + continue + } + go c.dispatch(req) } } @@ -215,6 +256,16 @@ func (c *websocketConnection) close() { c.chatQueueEventsCleanup() c.chatQueueEventsCleanup = nil } + if c.chatActivityEventsCleanup != nil { + c.chatActivityEventsCleanup() + c.chatActivityEventsCleanup = nil + } + if c.tunnelStateEventsCleanup != nil { + c.tunnelStateEventsCleanup() + c.tunnelStateEventsCleanup = nil + } + c.cleanupChatStreamSubscriptions() + c.cleanupWorkspaceSubscriptions() _ = c.conn.Close() }) } @@ -234,17 +285,21 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { } c.authorized = true + go c.writeLoop() c.startHistorySyncForwarder() c.startSettingsSyncForwarder() c.startTerminalEventForwarder() c.startSftpEventForwarder() c.startChatQueueEventForwarder() + c.startChatActivityForwarder() + c.startTunnelStateForwarder() c.startWebSocketHeartbeat() if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { c.close() return } c.replayTerminalSessionSnapshot() + c.replayTunnelStateSnapshot() } func (c *websocketConnection) startHistorySyncForwarder() { @@ -265,8 +320,33 @@ func (c *websocketConnection) startHistorySyncForwarder() { if !ok { return } - if err := c.writeHistoryEvent(websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...)); err != nil { - c.close() + if err := c.writeEvent("history.event", websocketHistorySyncPayload(event)); err != nil { + return + } + } + } + }() +} + +func (c *websocketConnection) startChatActivityForwarder() { + if c.chatActivityEvents != nil || c.chatActivityEventsCleanup != nil { + return + } + + activityEvents, cleanup := c.sm.SubscribeChatActivity() + c.chatActivityEvents = activityEvents + c.chatActivityEventsCleanup = cleanup + + go func() { + for { + select { + case <-c.done: + return + case event, ok := <-activityEvents: + if !ok { + return + } + if err := c.writeEvent("chat.activity", websocketChatActivityPayload(event)); err != nil { return } } @@ -292,12 +372,11 @@ func (c *websocketConnection) startSettingsSyncForwarder() { if !ok { return } - payload, err := websocketSettingsSyncPayload(event) + payload, err := websocketSettingsJSONPayload(event.GetSettingsJson()) if err != nil { return } - if err := c.writeSettingsEvent(payload); err != nil { - c.close() + if err := c.writeEvent("settings.event", payload); err != nil { return } } @@ -326,8 +405,7 @@ func (c *websocketConnection) startTerminalEventForwarder() { if !c.shouldForwardTerminalEvent(event) { continue } - if err := c.writeTerminalEvent(websocketTerminalEventPayload(event)); err != nil { - c.close() + if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(event)); err != nil { return } } @@ -356,8 +434,7 @@ func (c *websocketConnection) startSftpEventForwarder() { if !c.sm.WebSshTerminalEnabled() { continue } - if err := c.writeSftpEvent(websocketSftpEventPayload(event)); err != nil { - c.close() + if err := c.writeEvent("sftp.event", websocketSftpEventPayload(event)); err != nil { return } } @@ -383,8 +460,7 @@ func (c *websocketConnection) startChatQueueEventForwarder() { if !ok { return } - if err := c.writeChatQueueEvent(websocketChatQueueEventPayload(event)); err != nil { - c.close() + if err := c.writeEvent("chat_queue.event", websocketChatQueueEventPayload(event)); err != nil { return } } @@ -400,13 +476,12 @@ func (c *websocketConnection) replayTerminalSessionSnapshot() { if !c.terminalSessionAllowed(terminalSession) { continue } - if err := c.writeTerminalEvent(websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ + if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ Kind: "created", SessionId: terminalSession.GetId(), ProjectPathKey: terminalSession.GetProjectPathKey(), Session: terminalSession, })); err != nil { - c.close() return } } @@ -442,13 +517,19 @@ func (c *websocketConnection) startWebSocketHeartbeat() { case <-c.done: return case <-ticker.C: + c.lastInboundMu.Lock() + lastInbound := c.lastInboundAt + c.lastInboundMu.Unlock() + if time.Since(lastInbound) > c.idleTimeout() { + c.close() + return + } if err := c.writeEnvelope(websocketEnvelope{ Type: "ping", Payload: map[string]any{ "timestamp": time.Now().Unix(), }, }); err != nil { - c.close() return } } @@ -457,6 +538,23 @@ func (c *websocketConnection) startWebSocketHeartbeat() { }) } +// touchInboundActivity records liveness for every inbound frame and pushes the +// read deadline forward accordingly. +func (c *websocketConnection) touchInboundActivity() { + c.lastInboundMu.Lock() + c.lastInboundAt = time.Now() + c.lastInboundMu.Unlock() + _ = c.conn.SetReadDeadline(time.Now().Add(c.idleTimeout())) +} + +func (c *websocketConnection) idleTimeout() time.Duration { + period := c.cfg.WebSocketHeartbeatPeriod + if period <= 0 { + period = 15 * time.Second + } + return period*3 + 5*time.Second +} + func (c *websocketConnection) dispatch(req websocketRequest) { handler := websocketRequestHandlers[req.Type] if handler == nil { @@ -519,41 +617,101 @@ func (c *websocketConnection) writeError(requestID string, message string) error }) } -func (c *websocketConnection) writeHistoryEvent(payload any) error { +func (c *websocketConnection) writeEvent(eventType string, payload any) error { return c.writeEnvelope(websocketEnvelope{ - Type: "history.event", + Type: eventType, Payload: payload, }) } -func (c *websocketConnection) writeSettingsEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "settings.event", - Payload: payload, - }) -} +var errWriteQueueFull = errors.New("write queue full") -func (c *websocketConnection) writeTerminalEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "terminal.event", - Payload: payload, - }) -} +func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { + err := c.enqueueEnvelope(envelope) + if errors.Is(err, errWriteQueueFull) { + c.close() + } + return err +} + +// enqueueEnvelope waits up to writeTimeout for a slot when the outbox is +// momentarily full (token bursts to a slow reader); only a persistent backlog +// is fatal. The fast path allocates nothing. +func (c *websocketConnection) enqueueEnvelope(envelope websocketEnvelope) error { + select { + case <-c.done: + return errors.New("connection closed") + case c.outbox <- envelope: + return nil + default: + } -func (c *websocketConnection) writeSftpEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "sftp.event", - Payload: payload, - }) + timeout := c.writeTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.done: + return errors.New("connection closed") + case c.outbox <- envelope: + return nil + case <-timer.C: + return errWriteQueueFull + } } -func (c *websocketConnection) writeChatQueueEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "chat_queue.event", - Payload: payload, - }) +func (c *websocketConnection) writeLoop() { + for { + select { + case <-c.done: + return + case envelope := <-c.outbox: + if err := c.writeEnvelopeDirect(envelope); err != nil { + ok := false + for attempt := 0; attempt < websocketMaxWriteRetries; attempt++ { + select { + case <-c.done: + return + case <-time.After(websocketRetryBackoff): + } + if err := c.writeEnvelopeDirect(envelope); err == nil { + ok = true + break + } + } + if !ok { + c.close() + return + } + } + for drained := 0; drained < 64; drained++ { + select { + case extra := <-c.outbox: + if err := c.writeEnvelopeDirect(extra); err != nil { + c.close() + return + } + default: + goto batchDone + } + } + batchDone: + } + } } -func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { - return c.writer.write(envelope) +func (c *websocketConnection) writeEnvelopeDirect(envelope websocketEnvelope) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + if c.writeTimeout > 0 { + if err := c.conn.SetWriteDeadline(time.Now().Add(c.writeTimeout)); err != nil { + return err + } + defer func() { + _ = c.conn.SetWriteDeadline(time.Time{}) + }() + } + return c.conn.WriteJSON(envelope) } diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go new file mode 100644 index 000000000..f7097f687 --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -0,0 +1,313 @@ +package server + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" +) + +// chatStreamSubscription is one persistent per-conversation subscription on a +// websocket connection. It survives run boundaries and ends only on +// chat.unsubscribe, a replacing chat.subscribe, or connection close. +type chatStreamSubscription struct { + conversationID string + cancel func() +} + +func (c *websocketConnection) handleChatSubscribe(req websocketRequest) { + var payload struct { + ConversationID string `json:"conversation_id"` + AfterSeq int64 `json:"after_seq"` + StreamEpoch string `json:"stream_epoch"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.subscribe payload") + return + } + conversationID := strings.TrimSpace(payload.ConversationID) + if conversationID == "" { + _ = c.writeError(req.ID, "conversation_id is required") + return + } + + sub := c.sm.SubscribeConversationStream(conversationID, payload.AfterSeq, payload.StreamEpoch) + + events := make([]map[string]any, 0, len(sub.Events)) + for _, event := range sub.Events { + events = append(events, event.Payload) + } + resp := map[string]any{ + "conversation_id": sub.ConversationID, + "stream_epoch": sub.StreamEpoch, + "latest_seq": sub.LatestSeq, + "reset": sub.Reset, + "activity": websocketRunActivityPayload(sub.Activity), + "snapshot": websocketRunSnapshotPayload(sub.Snapshot), + "events": events, + } + + // Register (replacing any previous subscription for this conversation) + // before responding so no live event published after the replay boundary + // is dropped. + c.chatStreamsMu.Lock() + if c.chatStreams == nil { + c.chatStreams = make(map[string]*chatStreamSubscription) + } + if previous := c.chatStreams[conversationID]; previous != nil { + previous.cancel() + } + c.chatStreams[conversationID] = &chatStreamSubscription{ + conversationID: conversationID, + cancel: sub.Cleanup, + } + c.chatStreamsMu.Unlock() + + if err := c.writeResponse(req.ID, resp); err != nil { + sub.Cleanup() + return + } + + go c.forwardConversationEvents(conversationID, sub) +} + +// forwardConversationEvents pushes live stream events to the client. When the +// subscriber channel closes because it overflowed, the client is told to +// re-subscribe (resume by after_seq replays the missed tail from the buffer). +func (c *websocketConnection) forwardConversationEvents( + conversationID string, + sub *session.ConversationSubscription, +) { + defer sub.Cleanup() + for { + select { + case <-c.done: + return + case event, ok := <-sub.EventCh: + if !ok { + if sub.Overflowed() { + _ = c.writeEvent("chat.subscription_reset", map[string]any{ + "conversation_id": conversationID, + }) + } + return + } + if err := c.writeEvent("chat.event", event.Payload); err != nil { + return + } + } + } +} + +// handleChatActivities answers from gateway state only — no agent round-trip +// — so clients can reconcile running conversations while the desktop is +// offline. +func (c *websocketConnection) handleChatActivities(req websocketRequest) { + var body struct{} + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid chat.activities payload") + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), + }) +} + +func (c *websocketConnection) handleChatUnsubscribe(req websocketRequest) { + var payload struct { + ConversationID string `json:"conversation_id"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.unsubscribe payload") + return + } + conversationID := strings.TrimSpace(payload.ConversationID) + + c.chatStreamsMu.Lock() + if sub := c.chatStreams[conversationID]; sub != nil { + sub.cancel() + delete(c.chatStreams, conversationID) + } + c.chatStreamsMu.Unlock() + + _ = c.writeResponse(req.ID, map[string]any{"ok": true}) +} + +func (c *websocketConnection) cleanupChatStreamSubscriptions() { + c.chatStreamsMu.Lock() + for conversationID, sub := range c.chatStreams { + sub.cancel() + delete(c.chatStreams, conversationID) + } + c.chatStreamsMu.Unlock() +} + +func (c *websocketConnection) handleChatCommand(req websocketRequest) { + commandType, body, baseMessageRef, err := decodeChatCommandPayload(req.Payload) + if err != nil { + _ = c.writeError(req.ID, "invalid chat command payload") + return + } + + switch commandType { + case "chat.submit": + baseMessageRef = nil + case "chat.edit_resend": + if baseMessageRef == nil { + _ = c.writeError(req.ID, "base_message_ref is required") + return + } + if err := validateChatMessageRef(baseMessageRef); err != nil { + _ = c.writeError(req.ID, err.Error()) + return + } + case "chat.cancel": + c.handleChatCancel(req) + return + default: + _ = c.writeError(req.ID, "unsupported chat command") + return + } + + if err := normalizeChatRequestBody(&body); err != nil { + _ = c.writeError(req.ID, err.Error()) + return + } + + if !c.sm.IsOnline() { + _ = c.writeError(req.ID, "agent offline") + return + } + + runID := "chat-command-" + uuid.NewString() + updates, cleanupWatch := c.sm.WatchChatCommand(runID) + start := c.sm.StartChatCommand( + runID, + body.ConversationID, + body.Workdir, + body.ClientRequestID, + buildAcceptedChatCommandPayloads(body, baseMessageRef), + ) + + _ = c.writeResponse(req.ID, map[string]any{ + "run_id": start.RunID, + "conversation_id": start.ConversationID, + "accepted_seq": start.AcceptedSeq, + "deduped": false, + }) + + go c.forwardChatCommandUpdates(updates) + go dispatchAcceptedChatCommand( + context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, newChatTraceID(), + ) +} + +// forwardChatCommandUpdates relays pre-stream command outcomes (bound / +// queued_in_gui / failed) to the connection that issued the command. The +// watch is closed by the command's startup watchdog. +func (c *websocketConnection) forwardChatCommandUpdates(updates <-chan session.ChatCommandUpdate) { + for { + select { + case <-c.done: + return + case update, ok := <-updates: + if !ok { + return + } + payload := map[string]any{ + "run_id": update.RunID, + "client_request_id": update.ClientRequestID, + "phase": update.Phase, + } + if update.ConversationID != "" { + payload["conversation_id"] = update.ConversationID + } + if update.ErrorCode != "" { + payload["error_code"] = update.ErrorCode + } + if update.Message != "" { + payload["message"] = update.Message + } + if err := c.writeEvent("chat.command_update", payload); err != nil { + return + } + } + } +} + +func (c *websocketConnection) handleChatCancel(req websocketRequest) { + raw := req.Payload + // chat.cancel arrives either directly or wrapped in a chat.command + // envelope ({type, payload}); unwrap the latter. + var wrapper struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &wrapper); err == nil && + strings.TrimSpace(wrapper.Type) == "chat.cancel" && len(wrapper.Payload) > 0 { + raw = wrapper.Payload + } + + var payload struct { + ConversationID string `json:"conversation_id"` + RunID string `json:"run_id"` + } + if err := decodeWebSocketPayload(raw, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.cancel payload") + return + } + payload.ConversationID = strings.TrimSpace(payload.ConversationID) + payload.RunID = strings.TrimSpace(payload.RunID) + if payload.ConversationID == "" { + _ = c.writeError(req.ID, "conversation_id is required") + return + } + if !c.sm.IsOnline() { + _ = c.writeError(req.ID, "agent offline") + return + } + + // The run is not terminalized here: the activity flips to "cancelling", + // the agent's real terminal signal wins, and a watchdog force-finishes if + // the agent never reports back. + runID, active := c.sm.MarkConversationCancelling(payload.ConversationID, payload.RunID) + if !active { + _ = c.writeResponse(req.ID, map[string]any{ + "ok": true, "run_id": "", "conversation_id": payload.ConversationID, + }) + return + } + + timeout := 10 * time.Second + if c.cfg != nil && c.cfg.WebSocketWriteTimeout > 0 { + timeout = c.cfg.WebSocketWriteTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := c.sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: runID, + Timestamp: time.Now().Unix(), + Payload: buildChatCancelCommandPayload(payload.ConversationID), + }); err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + go watchChatCancel(c.sm, runID) + _ = c.writeResponse(req.ID, map[string]any{ + "ok": true, "run_id": runID, "conversation_id": payload.ConversationID, + }) +} + +const chatCancelWatchdogTimeout = 15 * time.Second + +func watchChatCancel(sm *session.Manager, runID string) { + time.Sleep(chatCancelWatchdogTimeout) + sm.ForceFinishRun(runID, "cancelled", "cancel_timeout", + "The desktop runtime did not confirm the cancellation in time.") +} diff --git a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go index 15c7c7943..72171a12a 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go @@ -27,7 +27,15 @@ func (c *websocketConnection) handleChatQueueRequest(req websocketRequest) { _ = c.writeError(req.ID, "invalid "+req.Type+" payload") return } + action := chatQueueActionFromRequestType(req.Type) + conversationID := strings.TrimSpace(body.ConversationID) if !c.sm.IsOnline() { + if action == "get" { + if event, ok := c.sm.ChatQueueSnapshot(conversationID); ok { + _ = c.writeResponse(req.ID, websocketChatQueueSnapshotResponsePayload(event)) + return + } + } _ = c.writeError(req.ID, "agent offline") return } @@ -37,8 +45,8 @@ func (c *websocketConnection) handleChatQueueRequest(req websocketRequest) { Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_ChatQueue{ ChatQueue: &gatewayv1.ChatQueueRequest{ - Action: chatQueueActionFromRequestType(req.Type), - ConversationId: strings.TrimSpace(body.ConversationID), + Action: action, + ConversationId: conversationID, ItemId: strings.TrimSpace(body.ItemID), Direction: strings.TrimSpace(body.Direction), Revision: body.Revision, @@ -49,6 +57,12 @@ func (c *websocketConnection) handleChatQueueRequest(req websocketRequest) { }, }) if err != nil { + if action == "get" { + if event, ok := c.sm.ChatQueueSnapshot(conversationID); ok { + _ = c.writeResponse(req.ID, websocketChatQueueSnapshotResponsePayload(event)) + return + } + } _ = c.writeError(req.ID, websocketErrorMessage(err)) return } @@ -72,3 +86,14 @@ func (c *websocketConnection) handleChatQueueRequest(req websocketRequest) { "revision": resp.GetRevision(), }) } + +func websocketChatQueueSnapshotResponsePayload(event *gatewayv1.ChatQueueEvent) map[string]any { + return map[string]any{ + "accepted": true, + "message": "", + "snapshot_json": event.GetSnapshotJson(), + "item_json": "", + "error_code": "", + "revision": event.GetRevision(), + } +} diff --git a/crates/agent-gateway/internal/server/websocket_git_handlers.go b/crates/agent-gateway/internal/server/websocket_git_handlers.go index 336c1af23..9097df7b0 100644 --- a/crates/agent-gateway/internal/server/websocket_git_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_git_handlers.go @@ -60,7 +60,7 @@ func (c *websocketConnection) handleGitRequest(req websocketRequest) { _ = c.writeError(req.ID, "unexpected agent response") return } - payload, err := websocketGitResultPayload(resp.GetResultJson()) + payload, err := unmarshalJSONPayload(resp.GetResultJson()) if err != nil { _ = c.writeError(req.ID, err.Error()) return diff --git a/crates/agent-gateway/internal/server/websocket_history_handlers.go b/crates/agent-gateway/internal/server/websocket_history_handlers.go index 7ac6e67e4..052f3f026 100644 --- a/crates/agent-gateway/internal/server/websocket_history_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_history_handlers.go @@ -65,10 +65,9 @@ func (c *websocketConnection) handleHistoryList(req websocketRequest) { } _ = c.writeResponse(req.ID, map[string]any{ - "conversations": conversations, - "total_count": resp.GetTotalCount(), - "running_conversation_ids": c.sm.ActiveChatRunConversationIDs(), - "running_conversations": websocketActiveChatRunSummariesPayload(c.sm.ActiveChatRunSummaries()), + "conversations": conversations, + "total_count": resp.GetTotalCount(), + "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), }) } diff --git a/crates/agent-gateway/internal/server/websocket_memory_handlers.go b/crates/agent-gateway/internal/server/websocket_memory_handlers.go index 92c18ecc5..b49e1cd09 100644 --- a/crates/agent-gateway/internal/server/websocket_memory_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_memory_handlers.go @@ -64,7 +64,7 @@ func (c *websocketConnection) handleMemoryManage(req websocketRequest) { return } - payloadValue, err := websocketMemoryResultPayload(resp.GetResultJson()) + payloadValue, err := unmarshalJSONPayload(resp.GetResultJson()) if err != nil { _ = c.writeError(req.ID, err.Error()) return diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index e59ea6885..8a7efdcef 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -2,109 +2,94 @@ package server import ( "testing" + "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" ) -func TestChatEventPayloadPreservesHostedSearch(t *testing.T) { - payload := chatEventPayload(&gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_HOSTED_SEARCH, - ConversationId: "conversation-1", - Data: `{"id":"search-1","provider":"codex","status":"completed","queries":["设计模式定义"],"sources":[{"url":"https://example.com/pattern","title":"设计模式"}],"round":2}`, - }, 7) - - if payload["type"] != "hosted_search" { - t.Fatalf("expected hosted_search type, got %#v", payload["type"]) - } - if payload["conversation_id"] != "conversation-1" { - t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) - } - if payload["id"] != "search-1" { - t.Fatalf("expected search id, got %#v", payload["id"]) - } - if payload["provider"] != "codex" { - t.Fatalf("expected provider, got %#v", payload["provider"]) +func TestWebsocketChatActivityPayloadCarriesRunIdentity(t *testing.T) { + updatedAt := time.UnixMilli(123456) + payload := websocketChatActivityPayload(session.ConversationActivityEvent{ + ConversationID: "conversation-1", + RunID: "run-1", + Running: true, + State: "running", + Workdir: "/workspace", + UpdatedAt: updatedAt, + }) + if payload["conversation_id"] != "conversation-1" || + payload["run_id"] != "run-1" || + payload["running"] != true || + payload["state"] != "running" || + payload["workdir"] != "/workspace" || + payload["updated_at"] != int64(123456) { + t.Fatalf("chat activity payload = %#v", payload) } - if payload["status"] != "completed" { - t.Fatalf("expected status, got %#v", payload["status"]) + + idle := websocketChatActivityPayload(session.ConversationActivityEvent{ + ConversationID: "conversation-1", + Running: false, + UpdatedAt: updatedAt, + }) + if idle["running"] != false { + t.Fatalf("idle activity payload = %#v", idle) } - if payload["seq"] != int64(7) { - t.Fatalf("expected seq 7, got %#v", payload["seq"]) + if _, hasRunID := idle["run_id"]; hasRunID { + t.Fatalf("idle activity should omit empty run_id: %#v", idle) } } -func TestChatEventPayloadPreservesToolCallDeltaType(t *testing.T) { - payload := chatEventPayload(&gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOOL_CALL, - ConversationId: "conversation-1", - Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"con"},"round":1}`, - }, 8) - - if payload["type"] != "tool_call_delta" { - t.Fatalf("expected tool_call_delta type, got %#v", payload["type"]) - } - if payload["conversation_id"] != "conversation-1" { - t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) - } - if payload["id"] != "call-write" { - t.Fatalf("expected tool call id, got %#v", payload["id"]) - } - if payload["name"] != "Write" { - t.Fatalf("expected tool name, got %#v", payload["name"]) +func TestWebsocketRunActivityAndSnapshotPayloads(t *testing.T) { + updatedAt := time.UnixMilli(7890) + activity := websocketRunActivityPayload(&session.RunActivity{ + RunID: "run-1", + ClientRequestID: "client-1", + State: "running", + ToolStatus: "Vibing", + ToolStatusIsCompaction: false, + StartedSeq: 17, + UpdatedAt: updatedAt, + }) + if activity["run_id"] != "run-1" || + activity["state"] != "running" || + activity["started_seq"] != int64(17) || + activity["tool_status"] != "Vibing" || + activity["client_request_id"] != "client-1" { + t.Fatalf("run activity payload = %#v", activity) } - if payload["seq"] != int64(8) { - t.Fatalf("expected seq 8, got %#v", payload["seq"]) + if payload := websocketRunActivityPayload(nil); payload != nil { + t.Fatalf("nil activity payload = %#v, want nil", payload) } -} -func TestActiveChatRunSummaryPayloadIncludesReplayCursor(t *testing.T) { - payload := websocketActiveChatRunSummariesPayload([]session.ActiveChatRunSummary{ - { - ConversationID: "conversation-1", - RequestID: "run-1", - Workdir: "/workspace", - FirstSeq: 4, - LatestSeq: 9, - RunEpoch: 2, - UpdatedAt: 123, - }, + snapshot := websocketRunSnapshotPayload(&session.RunSnapshot{ + RunID: "run-1", + Revision: 3, + EntriesJSON: `[{"kind":"assistant"}]`, + ToolStatus: "Compacting", }) - if len(payload) != 1 { - t.Fatalf("payload len = %d, want 1", len(payload)) + if snapshot["run_id"] != "run-1" || + snapshot["revision"] != int64(3) || + snapshot["entries_json"] != `[{"kind":"assistant"}]` { + t.Fatalf("run snapshot payload = %#v", snapshot) } - item := payload[0] - if item["run_id"] != "run-1" || - item["first_seq"] != int64(4) || - item["latest_seq"] != int64(9) || - item["run_epoch"] != int64(2) { - t.Fatalf("active run payload = %#v", item) + if payload := websocketRunSnapshotPayload(nil); payload != nil { + t.Fatalf("nil snapshot payload = %#v, want nil", payload) } } -func TestHistoryRunningPayloadIncludesReplayCursor(t *testing.T) { - payload := websocketHistorySyncPayload(&gatewayv1.HistorySyncEvent{ - Kind: "running", +func TestWebsocketChatQueueSnapshotResponsePayload(t *testing.T) { + payload := websocketChatQueueSnapshotResponsePayload(&gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, session.ActiveChatRunSummary{ - ConversationID: "conversation-1", - RequestID: "conversation-live-conversation-1", - FirstSeq: 2, - LatestSeq: 1, - RunEpoch: 5, - UpdatedAt: 123, + SnapshotJson: `{"conversationId":"conversation-1","revision":3,"items":[{"id":"queue-1"}]}`, + Revision: 3, }) - if payload["run_id"] != "conversation-live-conversation-1" || - payload["first_seq"] != int64(2) || - payload["latest_seq"] != int64(1) || - payload["run_epoch"] != int64(5) || - payload["updated_at"] != int64(123) { - t.Fatalf("history running payload = %#v", payload) + if payload["accepted"] != true || + payload["snapshot_json"] != `{"conversationId":"conversation-1","revision":3,"items":[{"id":"queue-1"}]}` || + payload["revision"] != uint64(3) || + payload["error_code"] != "" { + t.Fatalf("chat queue snapshot response payload = %#v", payload) } } diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index 76c672858..a42281c9f 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -15,7 +15,7 @@ import ( ) func websocketProtoPayload(message proto.Message, useProtoNames bool) map[string]any { - if isNilProtoMessage(message) { + if message == nil || (reflect.ValueOf(message).Kind() == reflect.Pointer && reflect.ValueOf(message).IsNil()) { return nil } raw, err := protojson.MarshalOptions{ @@ -33,14 +33,6 @@ func websocketProtoPayload(message proto.Message, useProtoNames bool) map[string return payload } -func isNilProtoMessage(message proto.Message) bool { - if message == nil { - return true - } - value := reflect.ValueOf(message) - return value.Kind() == reflect.Pointer && value.IsNil() -} - func coerceProtoJSONNumbers(payload map[string]any, descriptor protoreflect.MessageDescriptor, useProtoNames bool) { if payload == nil || descriptor == nil { return @@ -113,84 +105,90 @@ func websocketConversationSummaryPayload(conversation *gatewayv1.ConversationSum return websocketProtoPayload(conversation, true) } -func websocketActiveChatRunSummariesPayload(summaries []session.ActiveChatRunSummary) []map[string]any { - payload := make([]map[string]any, 0, len(summaries)) - for _, summary := range summaries { - conversationID := strings.TrimSpace(summary.ConversationID) - if conversationID == "" { - continue - } - item := map[string]any{ - "conversation_id": conversationID, - "cwd": strings.TrimSpace(summary.Workdir), - "updated_at": summary.UpdatedAt, - } - if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { - item["run_id"] = requestID - } - if summary.FirstSeq > 0 { - item["first_seq"] = summary.FirstSeq - } - if summary.LatestSeq > 0 { - item["latest_seq"] = summary.LatestSeq - } - if summary.RunEpoch > 0 { - item["run_epoch"] = summary.RunEpoch - } - payload = append(payload, item) - } - return payload -} - func websocketHistoryShareStatusPayload(share *gatewayv1.HistoryShareStatus) map[string]any { return websocketProtoPayload(share, true) } -func websocketHistorySyncPayload( - event *gatewayv1.HistorySyncEvent, - activeRuns ...session.ActiveChatRunSummary, -) map[string]any { +func websocketHistorySyncPayload(event *gatewayv1.HistorySyncEvent) map[string]any { payload := map[string]any{ "kind": strings.TrimSpace(event.GetKind()), "conversation_id": strings.TrimSpace(event.GetConversationId()), } - if conversation := event.GetConversation(); conversation != nil { payload["conversation"] = websocketConversationSummaryPayload(conversation) } - if payload["kind"] == "running" { - conversationID := strings.TrimSpace(event.GetConversationId()) - if conversationID == "" && event.GetConversation() != nil { - conversationID = strings.TrimSpace(event.GetConversation().GetId()) - } - for _, summary := range activeRuns { - if strings.TrimSpace(summary.ConversationID) != conversationID { - continue - } - if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { - payload["run_id"] = requestID - } - if summary.FirstSeq > 0 { - payload["first_seq"] = summary.FirstSeq - } - if summary.LatestSeq > 0 { - payload["latest_seq"] = summary.LatestSeq - } - if summary.RunEpoch > 0 { - payload["run_epoch"] = summary.RunEpoch - } - if summary.UpdatedAt > 0 { - payload["updated_at"] = summary.UpdatedAt - } - break - } + return payload +} + +func websocketChatActivityPayload(event session.ConversationActivityEvent) map[string]any { + payload := map[string]any{ + "conversation_id": event.ConversationID, + "running": event.Running, + "updated_at": event.UpdatedAt.UnixMilli(), + } + if event.RunID != "" { + payload["run_id"] = event.RunID + } + if event.ClientRequestID != "" { + payload["client_request_id"] = event.ClientRequestID } + if event.State != "" { + payload["state"] = event.State + } + if event.Workdir != "" { + payload["workdir"] = event.Workdir + } + return payload +} +// websocketRunningConversationsPayload is the wire shape for running-run +// summaries, shared by history.list and chat.activities. +func websocketRunningConversationsPayload(activities []session.RunActivity) []map[string]any { + runningConversations := make([]map[string]any, 0, len(activities)) + for _, activity := range activities { + runningConversations = append(runningConversations, map[string]any{ + "conversation_id": activity.ConversationID, + "run_id": activity.RunID, + "state": activity.State, + "cwd": activity.Workdir, + "updated_at": activity.UpdatedAt.UnixMilli(), + }) + } + return runningConversations +} + +func websocketRunActivityPayload(activity *session.RunActivity) map[string]any { + if activity == nil { + return nil + } + payload := map[string]any{ + "run_id": activity.RunID, + "state": activity.State, + "started_seq": activity.StartedSeq, + "updated_at": activity.UpdatedAt.UnixMilli(), + } + if activity.ToolStatus != "" { + payload["tool_status"] = activity.ToolStatus + payload["tool_status_is_compaction"] = activity.ToolStatusIsCompaction + } + if activity.ClientRequestID != "" { + payload["client_request_id"] = activity.ClientRequestID + } return payload } -func websocketSettingsSyncPayload(event *gatewayv1.SettingsSyncEvent) (map[string]any, error) { - return websocketSettingsJSONPayload(event.GetSettingsJson()) +func websocketRunSnapshotPayload(snapshot *session.RunSnapshot) map[string]any { + if snapshot == nil { + return nil + } + return map[string]any{ + "run_id": snapshot.RunID, + "revision": snapshot.Revision, + "entries_json": snapshot.EntriesJSON, + "tool_status": snapshot.ToolStatus, + "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, + "as_of_seq": snapshot.AsOfSeq, + } } func websocketSettingsJSONPayload(raw string) (map[string]any, error) { @@ -390,30 +388,14 @@ func websocketTerminalEventPayload(event *gatewayv1.TerminalEvent) map[string]an return payload } -func websocketMemoryResultPayload(raw string) (any, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return map[string]any{}, nil - } - - var payload any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("gateway memory response is not valid JSON") - } - if payload == nil { - return map[string]any{}, nil - } - return payload, nil -} - -func websocketGitResultPayload(raw string) (any, error) { +func unmarshalJSONPayload(raw string) (any, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return map[string]any{}, nil } var payload any if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("gateway git response is not valid JSON") + return nil, errors.New("response is not valid JSON") } if payload == nil { return map[string]any{}, nil @@ -442,14 +424,6 @@ func websocketRawPayloadJSON(raw json.RawMessage) (string, error) { return string(normalized), nil } -func nullableTrimmedString(value string) any { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil - } - return trimmed -} - func websocketOptionalUint32(value *int, field string) (uint32, error) { if value == nil { return 0, nil diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go index d3c273ca0..fb790b1c5 100644 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ b/crates/agent-gateway/internal/server/websocket_roundtrip.go @@ -73,9 +73,6 @@ func websocketErrorMessage(err error) string { if errors.Is(err, session.ErrAgentOffline) { return "agent offline" } - if errors.Is(err, session.ErrChatRunNotFound) { - return "chat stream not available" - } return err.Error() } diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 6d1ce6770..eb48e0e86 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -57,10 +57,10 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "sftp.delete": (*websocketConnection).handleSftpRequest, "sftp.transfer": (*websocketConnection).handleSftpRequest, "sftp.cancel": (*websocketConnection).handleSftpRequest, - "tunnel.list": (*websocketConnection).handleTunnelList, "tunnel.create": (*websocketConnection).handleTunnelCreate, "tunnel.update": (*websocketConnection).handleTunnelUpdate, "tunnel.close": (*websocketConnection).handleTunnelClose, + "tunnel.check": (*websocketConnection).handleTunnelCheck, "git.status": (*websocketConnection).handleGitRequest, "git.branches": (*websocketConnection).handleGitRequest, "git.init": (*websocketConnection).handleGitRequest, @@ -85,6 +85,13 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "git.push": (*websocketConnection).handleGitRequest, "cron.manage": (*websocketConnection).handleCronManage, "provider.models": (*websocketConnection).handleProviderModels, + "chat.subscribe": (*websocketConnection).handleChatSubscribe, + "chat.unsubscribe": (*websocketConnection).handleChatUnsubscribe, + "workspace.subscribe": (*websocketConnection).handleWorkspaceSubscribe, + "workspace.unsubscribe": (*websocketConnection).handleWorkspaceUnsubscribe, + "chat.activities": (*websocketConnection).handleChatActivities, + "chat.command": (*websocketConnection).handleChatCommand, + "chat.cancel": (*websocketConnection).handleChatCancel, "chat_queue.get": (*websocketConnection).handleChatQueueRequest, "chat_queue.get_item": (*websocketConnection).handleChatQueueRequest, "chat_queue.run_now": (*websocketConnection).handleChatQueueRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index 32cc57be1..7ca7be71a 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "testing" ) @@ -61,10 +60,10 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "sftp.delete", "sftp.transfer", "sftp.cancel", - "tunnel.list", "tunnel.create", "tunnel.update", "tunnel.close", + "tunnel.check", "git.status", "git.branches", "git.init", @@ -89,6 +88,13 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "git.push", "cron.manage", "provider.models", + "chat.subscribe", + "chat.unsubscribe", + "workspace.subscribe", + "workspace.unsubscribe", + "chat.activities", + "chat.command", + "chat.cancel", "chat_queue.get", "chat_queue.get_item", "chat_queue.run_now", @@ -114,14 +120,13 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "chat.resume", "chat.attach", "chat.detach", - "chat.cancel", "terminal.attach", "terminal.input", "terminal.resize", "terminal.detach", } { if websocketRequestHandlers[removedType] != nil { - t.Fatalf("websocketRequestHandlers[%q] should be removed; chat uses HTTP/SSE and terminal bytes use /ws/terminal", removedType) + t.Fatalf("websocketRequestHandlers[%q] should be removed; terminal bytes use /ws/terminal", removedType) } } } @@ -141,50 +146,3 @@ func TestDecodeWebSocketPayloadRejectsUnknownFields(t *testing.T) { t.Fatal("expected unknown payload field to be rejected") } } - -func TestTunnelTTLFromPayloadDefaultsOnlyWhenOmitted(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw json.RawMessage - camelValue uint32 - snakeValue uint32 - want uint32 - }{ - { - name: "omitted", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000"}`), - camelValue: 0, - snakeValue: 0, - want: websocketDefaultTunnelTTLSeconds, - }, - { - name: "camel explicit infinite", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":0}`), - camelValue: 0, - snakeValue: 0, - want: 0, - }, - { - name: "snake explicit infinite", - raw: json.RawMessage(`{"target_url":"http://localhost:3000","ttl_seconds":0}`), - camelValue: 0, - snakeValue: 0, - want: 0, - }, - { - name: "camel finite", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":900}`), - camelValue: 900, - snakeValue: 0, - want: 900, - }, - } - - for _, tt := range tests { - if got := tunnelTTLFromPayload(tt.raw, tt.camelValue, tt.snakeValue); got != tt.want { - t.Fatalf("%s: tunnelTTLFromPayload = %d, want %d", tt.name, got, tt.want) - } - } -} diff --git a/crates/agent-gateway/internal/server/websocket_settings_handlers.go b/crates/agent-gateway/internal/server/websocket_settings_handlers.go index 5ce275bfb..1c20c7fb5 100644 --- a/crates/agent-gateway/internal/server/websocket_settings_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_settings_handlers.go @@ -71,8 +71,15 @@ func (c *websocketConnection) handleSettingsUpdate(req websocketRequest) { _ = c.writeError(req.ID, "unexpected agent response") return } - if settingsResp.GetAccepted() && !settingsUpdateHasSshPatch(payloadJSON) { - c.sm.ApplySettingsJSONPreservingRemote(payloadJSON) + if settingsResp.GetAccepted() { + var patch map[string]any + hasSshPatch := json.Unmarshal([]byte(payloadJSON), &patch) == nil && patch != nil + if hasSshPatch { + _, hasSshPatch = patch["sshPatch"] + } + if !hasSshPatch { + c.sm.ApplySettingsJSONPreservingRemote(payloadJSON) + } } _ = c.writeResponse(req.ID, map[string]any{ @@ -81,15 +88,6 @@ func (c *websocketConnection) handleSettingsUpdate(req websocketRequest) { }) } -func settingsUpdateHasSshPatch(payloadJSON string) bool { - var payload map[string]any - if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { - return false - } - _, ok := payload["sshPatch"] - return ok -} - func (c *websocketConnection) handleSettingsResetSshKnownHost(req websocketRequest) { if !c.sm.WebSshTerminalEnabled() { _ = c.writeError(req.ID, "web SSH terminal is disabled in desktop Remote settings") diff --git a/crates/agent-gateway/internal/server/websocket_skills_handlers.go b/crates/agent-gateway/internal/server/websocket_skills_handlers.go index a8a51e790..c140cfcf6 100644 --- a/crates/agent-gateway/internal/server/websocket_skills_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_skills_handlers.go @@ -145,10 +145,16 @@ func (c *websocketConnection) handleSkillMetadataRead(req websocketRequest) { return } - _ = c.writeResponse(req.ID, map[string]any{ - "name": nullableTrimmedString(resp.GetName()), - "description": nullableTrimmedString(resp.GetDescription()), - }) + name := strings.TrimSpace(resp.GetName()) + description := strings.TrimSpace(resp.GetDescription()) + result := map[string]any{"name": any(nil), "description": any(nil)} + if name != "" { + result["name"] = name + } + if description != "" { + result["description"] = description + } + _ = c.writeResponse(req.ID, result) } func (c *websocketConnection) handleSkillTextRead(req websocketRequest) { diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go index 0766086a6..fdfd4dae1 100644 --- a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go @@ -1,189 +1,64 @@ package server import ( - "encoding/json" - "strings" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -const websocketDefaultTunnelTTLSeconds = 3600 - -type websocketTunnelCreatePayload struct { - TargetURL string `json:"targetUrl"` - TargetUrl string `json:"target_url"` - Name string `json:"name"` - TTLSeconds uint32 `json:"ttlSeconds"` - TtlSeconds uint32 `json:"ttl_seconds"` - ProjectPathKey string `json:"projectPathKey"` - ProjectPathKeySnake string `json:"project_path_key"` +type websocketTunnelMutationPayload struct { + TunnelID string `json:"tunnel_id"` + TargetURL string `json:"target_url"` + Name string `json:"name"` + TTLSeconds *uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"project_path_key"` } -func tunnelTTLFromPayload(raw json.RawMessage, camelValue uint32, snakeValue uint32) uint32 { - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return websocketDefaultTunnelTTLSeconds - } - if _, ok := fields["ttlSeconds"]; ok { - return camelValue - } - if _, ok := fields["ttl_seconds"]; ok { - return snakeValue - } - return websocketDefaultTunnelTTLSeconds +func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { + c.handleTunnelMutation(req, "create") } -type websocketTunnelUpdatePayload struct { - ID string `json:"id"` - TunnelID string `json:"tunnelId"` - TunnelId string `json:"tunnel_id"` - Slug string `json:"slug"` - TargetURL string `json:"targetUrl"` - TargetUrl string `json:"target_url"` - Name string `json:"name"` - TTLSeconds uint32 `json:"ttlSeconds"` - TtlSeconds uint32 `json:"ttl_seconds"` - ProjectPathKey string `json:"projectPathKey"` - ProjectPathKeySnake string `json:"project_path_key"` +func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { + c.handleTunnelMutation(req, "update") } -type websocketTunnelClosePayload struct { - ID string `json:"id"` - TunnelID string `json:"tunnelId"` - TunnelId string `json:"tunnel_id"` - Slug string `json:"slug"` +func (c *websocketConnection) handleTunnelClose(req websocketRequest) { + c.handleTunnelMutation(req, "close") } -func (c *websocketConnection) handleTunnelList(req websocketRequest) { - _ = c.writeResponse(req.ID, map[string]any{ - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) +func (c *websocketConnection) handleTunnelCheck(req websocketRequest) { + c.handleTunnelMutation(req, "check") } -func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { +// handleTunnelMutation forwards a webui tunnel mutation to the agent (the +// desired-state owner) and relays its verdict. State itself arrives on every +// client through the tunnel.state broadcast that the mutation triggers. +func (c *websocketConnection) handleTunnelMutation(req websocketRequest, action string) { if !c.sm.WebTunnelsEnabled() { _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") return } - var body websocketTunnelCreatePayload + var body websocketTunnelMutationPayload if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.create payload") - return - } - targetURL := strings.TrimSpace(body.TargetURL) - if targetURL == "" { - targetURL = strings.TrimSpace(body.TargetUrl) - } - ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - if projectPathKey == "" { - projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) - } - prepared, err := c.sm.PrepareTunnelCreate(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: targetURL, - Name: strings.TrimSpace(body.Name), - TtlSeconds: ttlSeconds, - ProjectPathKey: projectPathKey, - }, c.publicBaseURL()) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: prepared, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - controlResp := response.GetTunnelControlResp() - if controlResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - if controlResp.GetErrorMessage() != "" { - _ = c.writeError(req.ID, controlResp.GetErrorMessage()) - return - } - targetOverride := "" - if tunnel := controlResp.GetTunnel(); tunnel != nil { - targetOverride = tunnel.GetTargetUrl() - } - tunnel, err := c.sm.StorePreparedTunnel(prepared, targetOverride) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) -} - -func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { - if !c.sm.WebTunnelsEnabled() { - _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + _ = c.writeError(req.ID, "invalid tunnel."+action+" payload") return } - var body websocketTunnelUpdatePayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.update payload") - return - } - identifier := strings.TrimSpace(body.ID) - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelID) - } - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelId) - } - if identifier == "" { - identifier = strings.TrimSpace(body.Slug) - } - if identifier == "" { - _ = c.writeError(req.ID, "tunnel id is required") - return - } - targetURL := strings.TrimSpace(body.TargetURL) - if targetURL == "" { - targetURL = strings.TrimSpace(body.TargetUrl) - } - ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - if projectPathKey == "" { - projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) - } - prepared, err := c.sm.PrepareTunnelUpdate(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: identifier, - TargetUrl: targetURL, - Name: strings.TrimSpace(body.Name), - TtlSeconds: ttlSeconds, - ProjectPathKey: projectPathKey, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return + mutation := &gatewayv1.TunnelMutation{ + Action: action, + TunnelId: body.TunnelID, + TargetUrl: body.TargetURL, + Name: body.Name, + TtlSeconds: body.TTLSeconds, + ProjectPathKey: body.ProjectPathKey, } response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ RequestId: req.ID, Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: prepared, + Payload: &gatewayv1.GatewayEnvelope_TunnelMutation{ + TunnelMutation: mutation, }, }) if err != nil { @@ -194,129 +69,93 @@ func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { _ = c.writeError(req.ID, errResp.GetMessage()) return } - controlResp := response.GetTunnelControlResp() - if controlResp == nil { + result := response.GetTunnelMutationResult() + if result == nil { _ = c.writeError(req.ID, "unexpected agent response") return } - if controlResp.GetErrorMessage() != "" { - _ = c.writeError(req.ID, controlResp.GetErrorMessage()) - return - } - tunnel, err := c.sm.ApplyTunnelUpdate(controlResp.GetTunnel()) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) + if result.GetErrorMessage() != "" { + _ = c.writeError(req.ID, result.GetErrorMessage()) return } _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + "tunnel_id": result.GetTunnelId(), }) } -func (c *websocketConnection) handleTunnelClose(req websocketRequest) { - if !c.sm.WebTunnelsEnabled() { - _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") +func (c *websocketConnection) startTunnelStateForwarder() { + if c.tunnelStateEvents != nil || c.tunnelStateEventsCleanup != nil { return } - var body websocketTunnelClosePayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.close payload") - return - } - identifier := strings.TrimSpace(body.ID) - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelID) - } - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelId) - } - if identifier == "" { - identifier = strings.TrimSpace(body.Slug) - } - if identifier == "" { - _ = c.writeError(req.ID, "tunnel id is required") - return - } - - tunnel, err := c.sm.CloseTunnel(identifier) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - _ = c.sendToAgent(&gatewayv1.GatewayEnvelope{ - RequestId: "tunnel-close-" + tunnel.GetId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: &gatewayv1.TunnelControlRequest{ - Action: "close", - TunnelId: tunnel.GetId(), - Slug: tunnel.GetSlug(), - }, - }, - }) + tunnelStateEvents, cleanup := c.sm.SubscribeTunnelState() + c.tunnelStateEvents = tunnelStateEvents + c.tunnelStateEventsCleanup = cleanup - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) + go func() { + for { + select { + case <-c.done: + return + case snapshot, ok := <-tunnelStateEvents: + if !ok { + return + } + if err := c.writeEvent("tunnel.state", websocketTunnelStatePayload(snapshot)); err != nil { + return + } + } + } + }() } -func (c *websocketConnection) publicBaseURL() string { - return publicBaseURLFromHTTPRequest(c.req) +func (c *websocketConnection) replayTunnelStateSnapshot() { + _ = c.writeEvent("tunnel.state", websocketTunnelStatePayload(c.sm.TunnelStateSnapshot())) } -func websocketTunnelSummariesPayload( - summaries []*gatewayv1.TunnelSummary, - publicBaseURL string, -) []map[string]any { - payload := make([]map[string]any, 0, len(summaries)) - for _, summary := range summaries { - if item := websocketTunnelSummaryPayload(summary, publicBaseURL); item != nil { - payload = append(payload, item) +func websocketTunnelStatePayload(snapshot *gatewayv1.TunnelStateSnapshot) map[string]any { + if snapshot == nil { + return map[string]any{ + "revision": 0, + "agent_online": false, + "tunnels": []map[string]any{}, } } - return payload -} - -func websocketTunnelSummaryPayload( - summary *gatewayv1.TunnelSummary, - publicBaseURL string, -) map[string]any { - if summary == nil { - return nil - } - publicURL := strings.TrimSpace(summary.GetPublicUrl()) - if publicURL == "" { - publicURL = buildPublicTunnelURL(publicBaseURL, summary.GetSlug()) + tunnels := make([]map[string]any, 0, len(snapshot.GetTunnels())) + for _, tunnel := range snapshot.GetTunnels() { + if tunnel == nil { + continue + } + tunnels = append(tunnels, map[string]any{ + "id": tunnel.GetId(), + "slug": tunnel.GetSlug(), + "name": tunnel.GetName(), + "target_url": tunnel.GetTargetUrl(), + "public_path": tunnel.GetPublicPath(), + "created_at": tunnel.GetCreatedAt(), + "expires_at": tunnel.GetExpiresAt(), + "active_connections": tunnel.GetActiveConnections(), + "project_path_key": tunnel.GetProjectPathKey(), + "local": websocketTunnelHealthPayload(tunnel.GetLocal()), + }) } return map[string]any{ - "id": strings.TrimSpace(summary.GetId()), - "slug": strings.TrimSpace(summary.GetSlug()), - "name": strings.TrimSpace(summary.GetName()), - "targetUrl": strings.TrimSpace(summary.GetTargetUrl()), - "target_url": strings.TrimSpace(summary.GetTargetUrl()), - "publicUrl": publicURL, - "public_url": publicURL, - "createdAt": summary.GetCreatedAt(), - "created_at": summary.GetCreatedAt(), - "expiresAt": summary.GetExpiresAt(), - "expires_at": summary.GetExpiresAt(), - "activeConnections": summary.GetActiveConnections(), - "active_connections": summary.GetActiveConnections(), - "status": strings.TrimSpace(summary.GetStatus()), - "projectPathKey": strings.TrimSpace(summary.GetProjectPathKey()), - "project_path_key": strings.TrimSpace(summary.GetProjectPathKey()), + "revision": snapshot.GetRevision(), + "agent_online": snapshot.GetAgentOnline(), + "relay": websocketTunnelHealthPayload(snapshot.GetRelay()), + "tunnels": tunnels, } } -func buildPublicTunnelURL(publicBaseURL string, slug string) string { - publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") - slug = strings.TrimSpace(slug) - if publicBaseURL == "" || slug == "" { - return "" +func websocketTunnelHealthPayload(health *gatewayv1.TunnelHealth) map[string]any { + if health == nil { + return nil + } + return map[string]any{ + "status": health.GetStatus(), + "http_status": health.GetHttpStatus(), + "error": health.GetError(), + "checked_at": health.GetCheckedAt(), + "rtt_ms": health.GetRttMs(), } - return publicBaseURL + "/t/" + slug + "/" } diff --git a/crates/agent-gateway/internal/server/websocket_workspace_handlers.go b/crates/agent-gateway/internal/server/websocket_workspace_handlers.go new file mode 100644 index 000000000..56683c215 --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_workspace_handlers.go @@ -0,0 +1,127 @@ +package server + +import ( + "strings" + "sync" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// workspaceActivitySubscription is one workdir subscription on a websocket +// connection. It ends on workspace.unsubscribe, a replacing +// workspace.subscribe for the same workdir, or connection close. +type workspaceActivitySubscription struct { + cancel func() + done chan struct{} + once sync.Once +} + +func (s *workspaceActivitySubscription) close() { + s.once.Do(func() { + close(s.done) + s.cancel() + }) +} + +func (c *websocketConnection) handleWorkspaceSubscribe(req websocketRequest) { + var payload struct { + Workdir string `json:"workdir"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid workspace.subscribe payload") + return + } + workdir := strings.TrimSpace(payload.Workdir) + if workdir == "" { + _ = c.writeError(req.ID, "workdir is required") + return + } + + events, cancel := c.sm.SubscribeWorkspaceActivity(workdir) + sub := &workspaceActivitySubscription{ + cancel: cancel, + done: make(chan struct{}), + } + + c.workspaceSubsMu.Lock() + if c.workspaceSubs == nil { + c.workspaceSubs = make(map[string]*workspaceActivitySubscription) + } + if previous := c.workspaceSubs[workdir]; previous != nil { + previous.close() + } + c.workspaceSubs[workdir] = sub + c.workspaceSubsMu.Unlock() + + if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { + sub.close() + return + } + + go c.forwardWorkspaceActivity(sub, events) +} + +func (c *websocketConnection) forwardWorkspaceActivity( + sub *workspaceActivitySubscription, + events <-chan *gatewayv1.WorkspaceActivityEvent, +) { + for { + select { + case <-c.done: + return + case <-sub.done: + return + case event, ok := <-events: + if !ok { + return + } + if err := c.writeEvent("workspace.activity", websocketWorkspaceActivityPayload(event)); err != nil { + return + } + } + } +} + +func (c *websocketConnection) handleWorkspaceUnsubscribe(req websocketRequest) { + var payload struct { + Workdir string `json:"workdir"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid workspace.unsubscribe payload") + return + } + workdir := strings.TrimSpace(payload.Workdir) + + c.workspaceSubsMu.Lock() + if sub := c.workspaceSubs[workdir]; sub != nil { + sub.close() + delete(c.workspaceSubs, workdir) + } + c.workspaceSubsMu.Unlock() + + _ = c.writeResponse(req.ID, map[string]any{"ok": true}) +} + +func (c *websocketConnection) cleanupWorkspaceSubscriptions() { + c.workspaceSubsMu.Lock() + for workdir, sub := range c.workspaceSubs { + sub.close() + delete(c.workspaceSubs, workdir) + } + c.workspaceSubsMu.Unlock() +} + +func websocketWorkspaceActivityPayload(event *gatewayv1.WorkspaceActivityEvent) map[string]any { + changedPaths := event.GetChangedPaths() + if changedPaths == nil { + changedPaths = []string{} + } + return map[string]any{ + "workdir": event.GetWorkdir(), + "revision": event.GetRevision(), + "fs": event.GetFs(), + "git": event.GetGit(), + "changedPaths": changedPaths, + "truncated": event.GetTruncated(), + } +} diff --git a/crates/agent-gateway/internal/server/websocket_write_test.go b/crates/agent-gateway/internal/server/websocket_write_test.go new file mode 100644 index 000000000..d998f47bd --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_write_test.go @@ -0,0 +1,64 @@ +package server + +import ( + "errors" + "testing" + "time" +) + +func newEnqueueTestConnection(outboxSize int, writeTimeout time.Duration) *websocketConnection { + return &websocketConnection{ + outbox: make(chan websocketEnvelope, outboxSize), + writeTimeout: writeTimeout, + done: make(chan struct{}), + } +} + +func TestEnqueueEnvelopeWaitsForDrainedSlot(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, 500*time.Millisecond) + c.outbox <- websocketEnvelope{Type: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + <-c.outbox + }() + + if err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}); err != nil { + t.Fatalf("enqueueEnvelope with draining outbox = %v, want nil", err) + } +} + +func TestEnqueueEnvelopeFailsAfterPersistentBacklog(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, 50*time.Millisecond) + c.outbox <- websocketEnvelope{Type: "ping"} + + started := time.Now() + err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) + if !errors.Is(err, errWriteQueueFull) { + t.Fatalf("enqueueEnvelope with stuck outbox = %v, want errWriteQueueFull", err) + } + if waited := time.Since(started); waited < 50*time.Millisecond { + t.Fatalf("enqueueEnvelope gave up after %s, want at least the 50ms write timeout", waited) + } +} + +func TestEnqueueEnvelopeReturnsWhenConnectionCloses(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, time.Second) + c.outbox <- websocketEnvelope{Type: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + close(c.done) + }() + + err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) + if err == nil || err.Error() != "connection closed" { + t.Fatalf("enqueueEnvelope on closed connection = %v, want connection closed", err) + } +} diff --git a/crates/agent-gateway/internal/server/websocket_writer.go b/crates/agent-gateway/internal/server/websocket_writer.go deleted file mode 100644 index 750e89d93..000000000 --- a/crates/agent-gateway/internal/server/websocket_writer.go +++ /dev/null @@ -1,35 +0,0 @@ -package server - -import ( - "sync" - "time" - - "github.com/gorilla/websocket" -) - -type websocketConnectionWriter struct { - conn *websocket.Conn - timeout time.Duration - mu sync.Mutex -} - -func newWebsocketConnectionWriter(conn *websocket.Conn, timeout time.Duration) *websocketConnectionWriter { - return &websocketConnectionWriter{ - conn: conn, - timeout: timeout, - } -} - -func (w *websocketConnectionWriter) write(envelope websocketEnvelope) error { - w.mu.Lock() - defer w.mu.Unlock() - if w.timeout > 0 { - if err := w.conn.SetWriteDeadline(time.Now().Add(w.timeout)); err != nil { - return err - } - defer func() { - _ = w.conn.SetWriteDeadline(time.Time{}) - }() - } - return w.conn.WriteJSON(envelope) -} diff --git a/crates/agent-gateway/internal/session/activity_hub.go b/crates/agent-gateway/internal/session/activity_hub.go new file mode 100644 index 000000000..1750cbaa7 --- /dev/null +++ b/crates/agent-gateway/internal/session/activity_hub.go @@ -0,0 +1,94 @@ +package session + +import "sync" + +// chatActivityHub fans conversation activity transitions (running/idle with +// run ids) out to every authenticated webui connection. Events are composed +// inside the stream store's locked transitions, so per-conversation ordering +// is the log order. Activity is state-based: when a slow subscriber's buffer +// fills, the oldest pending event is dropped so the latest state still lands. +type chatActivityHub struct { + mu sync.Mutex + nextSubID int + subscribers map[int]chan ConversationActivityEvent +} + +func newChatActivityHub() *chatActivityHub { + return &chatActivityHub{ + subscribers: make(map[int]chan ConversationActivityEvent), + } +} + +// SubscribeChatActivity registers an activity listener. The current activity +// of every active conversation is replayed first so a fresh connection needs +// no separate hydration round-trip. +func (m *Manager) SubscribeChatActivity() (<-chan ConversationActivityEvent, func()) { + hub := m.convStreams.activityHub + + // Replay current activities before registering so a concurrent transition + // is delivered after its predecessor state, never before. The channel is + // sized to hold the whole replay: nothing reads it until this returns, so + // a blocking send here would wedge both mutexes. + m.convStreams.mu.Lock() + replay := make([]ConversationActivityEvent, 0, len(m.convStreams.streams)) + for _, stream := range m.convStreams.streams { + if stream.activity == nil { + continue + } + event := ConversationActivityEvent{ + ConversationID: stream.conversationID, + RunID: stream.activity.RunID, + ClientRequestID: stream.activity.ClientRequestID, + Running: true, + State: stream.activity.State, + Workdir: stream.activity.Workdir, + UpdatedAt: stream.activity.UpdatedAt, + } + if event.Workdir == "" { + event.Workdir = stream.workdir + } + replay = append(replay, event) + } + ch := make(chan ConversationActivityEvent, len(replay)+64) + hub.mu.Lock() + subID := hub.nextSubID + hub.nextSubID++ + hub.subscribers[subID] = ch + for _, event := range replay { + ch <- event + } + hub.mu.Unlock() + m.convStreams.mu.Unlock() + + cleanup := func() { + hub.mu.Lock() + // The channel is never closed: publish may hold a reference collected + // before cleanup ran. Subscribers exit via their own done signal. + delete(hub.subscribers, subID) + hub.mu.Unlock() + } + return ch, cleanup +} + +// publish is called while the stream store mutex is held (store.mu → hub.mu +// is the only lock order). Sends never block: on a full buffer the oldest +// pending event is discarded — activity is a state signal, latest wins. +func (hub *chatActivityHub) publish(event ConversationActivityEvent) { + hub.mu.Lock() + defer hub.mu.Unlock() + for _, ch := range hub.subscribers { + select { + case ch <- event: + continue + default: + } + select { + case <-ch: + default: + } + select { + case ch <- event: + default: + } + } +} diff --git a/crates/agent-gateway/internal/session/agent_session.go b/crates/agent-gateway/internal/session/agent_session.go index 5bee6705c..a22f0cb49 100644 --- a/crates/agent-gateway/internal/session/agent_session.go +++ b/crates/agent-gateway/internal/session/agent_session.go @@ -14,7 +14,8 @@ func NewAgentSession(auth AuthSnapshot) *AgentSession { SessionID: auth.SessionID, ConnectedAt: time.Now(), LastPing: time.Now(), - toAgent: make(chan *OutboundEnvelope, 64), + toAgent: make(chan *OutboundEnvelope, 512), + pingCh: make(chan *gatewayv1.GatewayEnvelope, 1), done: make(chan struct{}), streams: make(map[string]*agentStream), } @@ -48,6 +49,34 @@ func (s *AgentSession) Outbound() <-chan *OutboundEnvelope { return s.toAgent } +func (s *AgentSession) Pings() <-chan *gatewayv1.GatewayEnvelope { + return s.pingCh +} + +// SendPing queues a heartbeat on a dedicated lane that can never be starved +// by the shared outbound queue. Single producer (heartbeatLoop): a still-queued +// older ping is replaced so the freshest timestamp wins. +func (s *AgentSession) SendPing(env *gatewayv1.GatewayEnvelope) error { + select { + case <-s.done: + return ErrAgentOffline + default: + } + select { + case s.pingCh <- env: + default: + select { + case <-s.pingCh: + default: + } + select { + case s.pingCh <- env: + default: + } + } + return nil +} + func (s *AgentSession) Done() <-chan struct{} { return s.done } @@ -82,7 +111,8 @@ func (s *AgentSession) SendToAgentContext(ctx context.Context, env *gatewayv1.Ga case err := <-result: return err case <-ctx.Done(): - s.Close() + // The envelope stays queued; the writer skips it once its context is + // expired. A congested-but-alive session must not be torn down here. return ctx.Err() case <-s.done: return ErrAgentOffline diff --git a/crates/agent-gateway/internal/session/command_queue.go b/crates/agent-gateway/internal/session/command_queue.go new file mode 100644 index 000000000..7fa80e03c --- /dev/null +++ b/crates/agent-gateway/internal/session/command_queue.go @@ -0,0 +1,116 @@ +package session + +import ( + "context" + "sync" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + defaultCommandQueueTimeout = 30 * time.Second + maxCommandQueueSize = 10 +) + +type pendingCommand struct { + envelope *gatewayv1.GatewayEnvelope + result chan error + deadline time.Time +} + +type commandQueue struct { + mu sync.Mutex + items []pendingCommand + timeout time.Duration +} + +func newCommandQueue(timeout time.Duration) *commandQueue { + if timeout <= 0 { + timeout = defaultCommandQueueTimeout + } + return &commandQueue{ + timeout: timeout, + } +} + +func (q *commandQueue) Enqueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + q.mu.Lock() + q.evictExpiredLocked() + if len(q.items) >= maxCommandQueueSize { + q.mu.Unlock() + return ErrCommandQueueFull + } + resultCh := make(chan error, 1) + q.items = append(q.items, pendingCommand{ + envelope: env, + result: resultCh, + deadline: time.Now().Add(q.timeout), + }) + q.mu.Unlock() + + select { + case err := <-resultCh: + return err + case <-ctx.Done(): + return ctx.Err() + case <-time.After(q.timeout): + return ErrCommandQueueTimeout + } +} + +func (q *commandQueue) DrainTo(session *AgentSession) { + q.mu.Lock() + items := q.items + q.items = nil + q.mu.Unlock() + + now := time.Now() + for _, cmd := range items { + if now.After(cmd.deadline) { + select { + case cmd.result <- ErrCommandQueueTimeout: + default: + } + continue + } + ctx, cancel := context.WithDeadline(context.Background(), cmd.deadline) + err := session.SendToAgentContext(ctx, cmd.envelope) + cancel() + select { + case cmd.result <- err: + default: + } + } +} + +func (q *commandQueue) FailAll(err error) { + q.mu.Lock() + items := q.items + q.items = nil + q.mu.Unlock() + + for _, cmd := range items { + select { + case cmd.result <- err: + default: + } + } +} + +func (q *commandQueue) evictExpiredLocked() { + now := time.Now() + n := 0 + for _, cmd := range q.items { + if now.After(cmd.deadline) { + select { + case cmd.result <- ErrCommandQueueTimeout: + default: + } + continue + } + q.items[n] = cmd + n++ + } + q.items = q.items[:n] +} diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go new file mode 100644 index 000000000..e2e6d7d8c --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -0,0 +1,370 @@ +package session + +import ( + "strings" + "time" + + "github.com/liveagent/agent-gateway/internal/chatwire" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// Ingress normalization: the three agent-facing gRPC payloads (ChatEvent, +// ChatControlEvent, ChatRuntimeSnapshot) converge here into one append API on +// the conversation stream store. Payload shaping and tool-result trimming +// happen exactly once, so every subscriber observes identical events. + +func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) { + if event == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(requestID) + if runID == "" { + return + } + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID := s.resolveConversationLocked(runID, strings.TrimSpace(event.GetConversationId()), now) + if conversationID == "" { + return + } + existingStream := s.streams[conversationID] + streamWasUnknown := existingStream == nil || + (existingStream.lastSeq == 0 && existingStream.activity == nil) + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + payload := chatwire.EventPayload(event, 0) + eventType, _ := payload["type"].(string) + if eventType == "" { + eventType = chatwire.EventTypeName(event.GetType()) + } + + switch event.GetType() { + case gatewayv1.ChatEvent_DONE: + delete(payload, "type") + delete(payload, "seq") + s.runFinishedLocked(stream, runID, "completed", "", "", payload, now) + return + case gatewayv1.ChatEvent_ERROR: + message, _ := payload["message"].(string) + delete(payload, "type") + delete(payload, "seq") + delete(payload, "message") + s.runFinishedLocked(stream, runID, "failed", "", strings.TrimSpace(message), payload, now) + return + case gatewayv1.ChatEvent_USER_MESSAGE: + if record := s.runs[runID]; record != nil && record.userMessageSeeded { + // The gateway already appended this run's user_message at accept + // time; swallow the agent echo so the message appears once. + return + } + } + + if stream.runFinishedRecently(runID) { + // Late straggler after a forced or duplicate terminal; drop it. + return + } + + if event.GetType() == gatewayv1.ChatEvent_USER_MESSAGE { + // A GUI-local edit-resend: the desktop truncated its own history and + // stamped the truncation base onto its user_message. Broadcast the + // same rebased event the webui edit path seeds, so every subscriber + // truncates before the new user message renders (webui commands never + // reach here — their echo was swallowed above). + if ref, ok := payload["base_message_ref"].(map[string]any); ok { + messageID, _ := ref["message_id"].(string) + contentHash, _ := ref["content_hash"].(string) + if strings.TrimSpace(messageID) != "" || strings.TrimSpace(contentHash) != "" { + record := s.runRecordLocked(runID, conversationID) + if !record.rebaseSeeded { + record.rebaseSeeded = true + s.appendSeededPayloadsLocked(stream, runID, record.clientRequestID, []map[string]any{{ + "type": StreamEventRebased, + "base_message_ref": ref, + "reason": "edit_resend", + }}, now) + } + } + } + } + + workdir, _ := payload["workdir"].(string) + s.runStartedLocked(stream, runID, strings.TrimSpace(workdir), now) + if stream.activity == nil || stream.activity.RunID != runID { + // runStartedLocked declined (e.g. the run finished during + // supersession bookkeeping); do not attribute events to another run. + return + } + if streamWasUnknown && event.GetType() != gatewayv1.ChatEvent_USER_MESSAGE { + // A mid-run delta recreated this stream (gateway restarted while the + // run was streaming): the run's earlier events are unrecoverable from + // the log, so late joiners must hydrate from the runtime snapshot. + stream.runNeedsSnapshot = true + } + + if event.GetType() == gatewayv1.ChatEvent_TOOL_STATUS { + status, _ := payload["status"].(string) + isCompaction, _ := payload["isCompaction"].(bool) + stream.activity.ToolStatus = strings.TrimSpace(status) + stream.activity.ToolStatusIsCompaction = isCompaction + stream.activity.UpdatedAt = now + } + + delete(payload, "seq") + s.appendEventLocked(stream, runID, eventType, payload, now) +} + +func (m *Manager) ingestChatControl(requestID string, control *gatewayv1.ChatControlEvent) { + if control == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(requestID) + if runID == "" { + runID = strings.TrimSpace(control.GetRequestId()) + } + if runID == "" { + return + } + controlType := strings.TrimSpace(control.GetType()) + if controlType == "" { + controlType = strings.TrimSpace(control.GetState()) + } + errorCode := strings.TrimSpace(control.GetErrorCode()) + message := strings.TrimSpace(control.GetMessage()) + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID := s.resolveConversationLocked(runID, strings.TrimSpace(control.GetConversationId()), now) + if conversationID == "" { + // A control for a run the gateway has no conversation for yet (the + // binding signal must carry a conversation id); ignore. + return + } + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + switch controlType { + case "started": + s.runStartedLocked(stream, runID, "", now) + case "completed", "failed", "cancelled": + s.runFinishedLocked(stream, runID, controlType, errorCode, message, nil, now) + case "queued_in_gui": + s.markRunQueuedInGUILocked(stream, runID, now) + case "accepted", "delivered", "claimed", "starting": + record := s.runRecordLocked(runID, conversationID) + s.markRunQueuedLocked(stream, runID, record.clientRequestID, now) + } +} + +// markRunQueuedInGUILocked handles a command the desktop app parked in its +// prompt queue: the run will not start now. Any provisionally seeded entries +// are compensated with a run_queued event so clients drop them (the prompt is +// visible in the queue UI instead), and the agent's later user_message echo — +// when the queued item finally runs — must pass through. +func (s *conversationStreamStore) markRunQueuedInGUILocked( + stream *conversationStream, + runID string, + now time.Time, +) { + if stream.runFinishedRecently(runID) { + return + } + record := s.runRecordLocked(runID, stream.conversationID) + record.queuedInGUI = true + seeded := record.userMessageSeeded + record.userMessageSeeded = false + // Seeds deferred at accept time never reached the log: drop them. The + // prompt now lives in the desktop queue (editable there), and the agent's + // echo is the authoritative text when the item eventually runs. + record.deferredSeeds = nil + + if seeded { + payload := map[string]any{} + if record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + s.appendEventLocked(stream, runID, StreamEventRunQueued, payload, now) + } + if stream.activity != nil && stream.activity.RunID == runID { + stream.activity = nil + s.publishActivityLocked(stream, now) + } + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: runID, + ClientRequestID: record.clientRequestID, + ConversationID: stream.conversationID, + Phase: "queued_in_gui", + }) +} + +func (m *Manager) ingestRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapshot) { + if snapshot == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(snapshot.GetRunId()) + conversationID := strings.TrimSpace(snapshot.GetConversationId()) + if runID == "" || conversationID == "" { + return + } + state := strings.TrimSpace(snapshot.GetState()) + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID = s.resolveConversationLocked(runID, conversationID, now) + existingStream := s.streams[conversationID] + streamWasUnknown := existingStream == nil || (existingStream.lastSeq == 0 && existingStream.activity == nil) + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + switch state { + case "completed", "failed", "cancelled": + s.runFinishedLocked(stream, runID, state, "", "", nil, now) + return + } + if stream.runFinishedRecently(runID) { + return + } + + next := &RunSnapshot{ + RunID: runID, + Revision: snapshot.GetRevision(), + EntriesJSON: strings.TrimSpace(snapshot.GetEntriesJson()), + ToolStatus: strings.TrimSpace(snapshot.GetToolStatus()), + ToolStatusIsCompaction: snapshot.GetToolStatusIsCompaction(), + Workdir: strings.TrimSpace(snapshot.GetCwd()), + AsOfSeq: stream.lastSeq, + UpdatedAt: now, + } + if current := stream.latestSnapshot; current != nil && + current.RunID == runID && + current.Revision > next.Revision { + // Stale revision; keep the newer snapshot. + return + } + stream.latestSnapshot = next + stream.updatedAt = now + stream.lastEventAt = now + + if state == "running" || state == "" { + if streamWasUnknown { + // The gateway (re)started while this run was already streaming; + // buffered history is gone, so late joiners need the snapshot. + stream.runNeedsSnapshot = true + } + s.runStartedLocked(stream, runID, next.Workdir, now) + if stream.activity != nil && stream.activity.RunID == runID { + if next.ToolStatus != "" || stream.activity.ToolStatus != "" { + stream.activity.ToolStatus = next.ToolStatus + stream.activity.ToolStatusIsCompaction = next.ToolStatusIsCompaction + } + stream.activity.UpdatedAt = now + } + } + + if stream.snapshotDirty { + // The agent reconnected mid-run: tokens streamed during the outage are + // unrecoverable, so push the snapshot inline for attached subscribers. + stream.snapshotDirty = false + s.publishSnapshotLocked(stream, runID, next, now) + } +} + +// publishSnapshotLocked delivers a seq-less snapshot event to current +// subscribers without storing it in the log. +func (s *conversationStreamStore) publishSnapshotLocked( + stream *conversationStream, + runID string, + snapshot *RunSnapshot, + now time.Time, +) { + payload := map[string]any{ + "conversation_id": stream.conversationID, + "run_id": runID, + "type": StreamEventSnapshot, + "revision": snapshot.Revision, + "entries_json": snapshot.EntriesJSON, + "tool_status": snapshot.ToolStatus, + "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, + "as_of_seq": snapshot.AsOfSeq, + } + event := &ConversationEvent{ + ConversationID: stream.conversationID, + RunID: runID, + Seq: 0, + Type: StreamEventSnapshot, + Payload: payload, + ReceivedAt: now, + } + s.publishLocked(stream, event) +} + +// resolveConversationLocked determines the conversation a run belongs to, +// binding a pending webui command when the first agent signal carries a +// conversation id. +func (s *conversationStreamStore) resolveConversationLocked( + runID string, + conversationID string, + now time.Time, +) string { + if pending := s.pendingRuns[runID]; pending != nil && conversationID != "" { + s.bindPendingRunLocked(pending, conversationID, now) + } + if conversationID != "" { + s.runRecordLocked(runID, conversationID) + return conversationID + } + if record := s.runs[runID]; record != nil { + return record.conversationID + } + return "" +} + +func (s *conversationStreamStore) bindPendingRunLocked( + pending *pendingChatRun, + conversationID string, + now time.Time, +) { + delete(s.pendingRuns, pending.runID) + stream := s.streamLocked(conversationID, now) + if pending.workdir != "" { + stream.workdir = pending.workdir + } + record := s.runRecordLocked(pending.runID, conversationID) + record.clientRequestID = pending.clientRequestID + s.markRunQueuedLocked(stream, pending.runID, pending.clientRequestID, now) + s.appendSeededPayloadsLocked(stream, pending.runID, pending.clientRequestID, pending.seeded, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(pending.seeded) + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: pending.runID, + ClientRequestID: pending.clientRequestID, + ConversationID: conversationID, + Phase: "bound", + }) +} + +// noteAgentEpochLocked tracks the agent session epoch per stream: when the +// agent reconnects mid-run, tokens streamed during the outage are lost, so +// the next runtime snapshot is pushed inline and offered to late joiners. +func (s *conversationStreamStore) noteAgentEpochLocked(stream *conversationStream, epoch uint64) { + if epoch == 0 || stream.agentEpoch == epoch { + return + } + if stream.agentEpoch != 0 && stream.activity != nil { + stream.snapshotDirty = true + stream.runNeedsSnapshot = true + } + stream.agentEpoch = epoch +} diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go new file mode 100644 index 000000000..d7799d0e8 --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -0,0 +1,1105 @@ +package session + +import ( + "strings" + "sync" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// The conversation stream store is the authoritative relay state for chat: +// one ordered event log per conversation with a monotonic seq, a single +// current-run activity record, and persistent per-conversation subscribers. +// Runs are events inside the stream, not stream boundaries. +// +// Invariants (all enforced under the single store mutex): +// 1. Seq is conversation-scoped and monotonic; runs do not own seq. +// 2. run_finished is emitted exactly once per run — the first terminal +// signal wins, later duplicates are swallowed via the finished-run ring. +// 3. Run handoff is supersession: run_started(B) while A is running +// atomically synthesizes run_finished(A) first. +// 4. Activity events are composed inside the locked transition that changed +// them, so they always carry the run id. +// 5. Subscriber sends happen under the mutex (non-blocking); an overflowing +// subscriber is closed and resumes by re-subscribing with after_seq. +const ( + conversationEventRetention = 10 * time.Minute + conversationMaxEvents = 4096 + conversationMaxEventBytes = 8 << 20 + conversationIdleRetention = 30 * time.Minute + conversationStaleRunTimeout = 10 * time.Minute + conversationOfflineRunTimeout = 30 * time.Minute + conversationReaperInterval = time.Minute + conversationFinishedRunMemory = 8 + conversationSubscriberBuffer = 256 + pendingChatRunRetention = 5 * time.Minute + // conversationRunReportLostTimeout is the grace window before a run absent + // from the desktop's run reports is finalized as lost. + conversationRunReportLostTimeout = 15 * time.Second +) + +const ( + RunActivityQueued = "queued" + RunActivityRunning = "running" + RunActivityCancelling = "cancelling" +) + +// Normalized event types appended to the conversation log. +const ( + StreamEventRunStarted = "run_started" + StreamEventRunFinished = "run_finished" + StreamEventRunQueued = "run_queued" + StreamEventSnapshot = "snapshot" + // StreamEventRebased signals an edit-resend truncation: subscribers drop + // the edited user message and everything after it before the new + // user_message arrives. Seeded by the gateway for webui edit_resend + // commands and synthesized on ingress for GUI-local edits. + StreamEventRebased = "rebased" +) + +// RunActivity describes the current run of a conversation. A nil activity +// means the conversation is idle. +type RunActivity struct { + ConversationID string + RunID string + ClientRequestID string + State string + ToolStatus string + ToolStatusIsCompaction bool + StartedSeq int64 + Workdir string + UpdatedAt time.Time +} + +// RunSnapshot is the latest runtime snapshot for a conversation's run. It is +// not part of the seq log; it hydrates late joiners when the buffer cannot +// cover the active run from its start. +type RunSnapshot struct { + RunID string + Revision int64 + EntriesJSON string + ToolStatus string + ToolStatusIsCompaction bool + Workdir string + // AsOfSeq is the conversation's last log seq when this snapshot was + // ingested: the snapshot already represents every event up to and + // including it, so clients rebuilding from the snapshot must only apply + // replayed events with a higher seq. + AsOfSeq int64 + UpdatedAt time.Time +} + +// ConversationEvent is one entry of a conversation log. Payload is the final +// wire shape (including conversation_id/run_id/seq/type) and is frozen after +// append — subscribers must never mutate it. +type ConversationEvent struct { + ConversationID string + RunID string + Seq int64 + Type string + Payload map[string]any + ReceivedAt time.Time + + approxBytes int +} + +// ConversationActivityEvent is the broadcast shape for the chat.activity hub. +type ConversationActivityEvent struct { + ConversationID string + RunID string + ClientRequestID string + Running bool + State string + Workdir string + UpdatedAt time.Time +} + +// ChatCommandUpdate notifies the connection that issued a chat command about +// pre-stream outcomes. +type ChatCommandUpdate struct { + RunID string + ClientRequestID string + ConversationID string + Phase string // "bound" | "queued_in_gui" | "failed" + ErrorCode string + Message string +} + +type streamSubscriber struct { + id int + ch chan *ConversationEvent + overflowed bool + closed bool +} + +type conversationStream struct { + conversationID string + streamEpoch string + workdir string + lastSeq int64 + events []*ConversationEvent + eventsBytes int + evictedThroughSeq int64 + activity *RunActivity + finishedRuns []string + latestSnapshot *RunSnapshot + agentEpoch uint64 + snapshotDirty bool + // runNeedsSnapshot marks an active run whose early events the buffer + // cannot reproduce (gateway restarted mid-run, or the agent reconnected + // mid-run and tokens were lost) — late joiners hydrate from the snapshot. + runNeedsSnapshot bool + subscribers map[int]*streamSubscriber + lastEventAt time.Time + updatedAt time.Time +} + +type chatRunRecord struct { + conversationID string + clientRequestID string + // userMessageSeeded marks runs whose user_message the gateway appended at + // accept time; the agent's later USER_MESSAGE echo is swallowed so the + // message appears exactly once. + userMessageSeeded bool + // firstSeededSeq is the seq of the run's first gateway-seeded event so a + // run started via supersession still protects its seeded user_message + // from retention eviction. + firstSeededSeq int64 + // deferredSeeds holds seeded payloads of a command accepted while another + // run was active: appended only when this run actually starts (or fails), + // dropped when it parks in the desktop prompt queue — so a queue-bound + // prompt never flashes a transcript bubble. + deferredSeeds []map[string]any + // queuedInGUI marks commands the desktop app parked in its prompt queue; + // the startup watchdog must leave them alone. + queuedInGUI bool + // rebaseSeeded marks runs whose rebased event was already appended from + // the agent's ref-bearing user_message, so a reconnect replay of the same + // event cannot seed a second truncation. + rebaseSeeded bool +} + +type pendingChatRun struct { + runID string + clientRequestID string + workdir string + seeded []map[string]any + createdAt time.Time +} + +type conversationStreamStore struct { + mu sync.Mutex + streams map[string]*conversationStream + pendingRuns map[string]*pendingChatRun + runs map[string]*chatRunRecord + commandWatchers map[string][]chan ChatCommandUpdate + nextSubID int + + activityHub *chatActivityHub + + reaperOnce sync.Once + isOnline func() bool + + // tunable in tests + eventRetention time.Duration + maxEvents int + maxEventBytes int + idleRetention time.Duration + staleRunTimeout time.Duration + offlineRunTimeout time.Duration + runReportLostTimeout time.Duration + reaperInterval time.Duration +} + +func newConversationStreamStore(isOnline func() bool) *conversationStreamStore { + return &conversationStreamStore{ + streams: make(map[string]*conversationStream), + pendingRuns: make(map[string]*pendingChatRun), + runs: make(map[string]*chatRunRecord), + commandWatchers: make(map[string][]chan ChatCommandUpdate), + activityHub: newChatActivityHub(), + isOnline: isOnline, + eventRetention: conversationEventRetention, + maxEvents: conversationMaxEvents, + maxEventBytes: conversationMaxEventBytes, + idleRetention: conversationIdleRetention, + staleRunTimeout: conversationStaleRunTimeout, + offlineRunTimeout: conversationOfflineRunTimeout, + runReportLostTimeout: conversationRunReportLostTimeout, + reaperInterval: conversationReaperInterval, + } +} + +func (s *conversationStreamStore) streamLocked(conversationID string, now time.Time) *conversationStream { + stream := s.streams[conversationID] + if stream == nil { + stream = &conversationStream{ + conversationID: conversationID, + streamEpoch: uuid.NewString(), + subscribers: make(map[int]*streamSubscriber), + updatedAt: now, + } + s.streams[conversationID] = stream + s.startReaper() + } + return stream +} + +func (s *conversationStreamStore) evictStreamLocked(stream *conversationStream, now time.Time) { + cutoff := now.Add(-s.eventRetention) + activeStart := int64(0) + if stream.activity != nil { + activeStart = stream.activity.StartedSeq + } + drop := 0 + for drop < len(stream.events) { + event := stream.events[drop] + overCap := len(stream.events)-drop > s.maxEvents || + stream.eventsBytes > s.maxEventBytes + expired := event.ReceivedAt.Before(cutoff) + if !overCap && !expired { + break + } + if !overCap && activeStart > 0 && event.Seq >= activeStart { + // Retention never evicts events of the active run; only hard caps do. + break + } + stream.eventsBytes -= event.approxBytes + if event.Seq > stream.evictedThroughSeq { + stream.evictedThroughSeq = event.Seq + } + drop++ + } + if drop > 0 { + remaining := len(stream.events) - drop + copy(stream.events, stream.events[drop:]) + for i := remaining; i < len(stream.events); i++ { + stream.events[i] = nil + } + stream.events = stream.events[:remaining] + } +} + +// ConversationSubscription is the result of subscribing to a conversation +// stream. The subscription persists across runs; EventCh closes only on +// Cleanup or when the subscriber overflows (check Overflowed, then +// re-subscribe with after_seq to resume without loss). +type ConversationSubscription struct { + ConversationID string + StreamEpoch string + LatestSeq int64 + Reset bool + Activity *RunActivity + Snapshot *RunSnapshot + Events []*ConversationEvent + EventCh <-chan *ConversationEvent + Cleanup func() + Overflowed func() bool +} + +func (m *Manager) SubscribeConversationStream( + conversationID string, + afterSeq int64, + clientEpoch string, +) *ConversationSubscription { + s := m.convStreams + conversationID = strings.TrimSpace(conversationID) + clientEpoch = strings.TrimSpace(clientEpoch) + if afterSeq < 0 { + afterSeq = 0 + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + stream := s.streamLocked(conversationID, now) + s.evictStreamLocked(stream, now) + + reset := false + if clientEpoch != "" && clientEpoch != stream.streamEpoch { + reset = true + } + if afterSeq > stream.lastSeq { + reset = true + } + if afterSeq > 0 && afterSeq < stream.evictedThroughSeq { + reset = true + } + if reset { + afterSeq = 0 + } + + replay := make([]*ConversationEvent, 0, len(stream.events)) + for _, event := range stream.events { + if event.Seq > afterSeq { + replay = append(replay, event) + } + } + + var snapshot *RunSnapshot + if stream.activity != nil && + stream.latestSnapshot != nil && + stream.latestSnapshot.RunID == stream.activity.RunID && + afterSeq < stream.activity.StartedSeq && + (stream.evictedThroughSeq >= stream.activity.StartedSeq || stream.runNeedsSnapshot) { + // The buffer cannot reproduce the active run from its start; hand the + // client the runtime snapshot to rebuild the live tail. + snapshotCopy := *stream.latestSnapshot + snapshot = &snapshotCopy + } + + var activity *RunActivity + if stream.activity != nil { + activityCopy := *stream.activity + activity = &activityCopy + } + + s.nextSubID++ + sub := &streamSubscriber{ + id: s.nextSubID, + ch: make(chan *ConversationEvent, conversationSubscriberBuffer), + } + stream.subscribers[sub.id] = sub + + cleanup := func() { + s.mu.Lock() + defer s.mu.Unlock() + current := s.streams[conversationID] + if current == nil { + return + } + if existing, ok := current.subscribers[sub.id]; ok && existing == sub { + delete(current.subscribers, sub.id) + if !sub.closed { + sub.closed = true + close(sub.ch) + } + } + } + overflowed := func() bool { + s.mu.Lock() + defer s.mu.Unlock() + return sub.overflowed + } + + return &ConversationSubscription{ + ConversationID: conversationID, + StreamEpoch: stream.streamEpoch, + LatestSeq: stream.lastSeq, + Reset: reset, + Activity: activity, + Snapshot: snapshot, + Events: replay, + EventCh: sub.ch, + Cleanup: cleanup, + Overflowed: overflowed, + } +} + +// ActiveConversationActivities returns the current activity of every +// conversation with an active run (for history.list hydration). +func (m *Manager) ActiveConversationActivities() []RunActivity { + s := m.convStreams + s.mu.Lock() + defer s.mu.Unlock() + activities := make([]RunActivity, 0, len(s.streams)) + for _, stream := range s.streams { + if stream.activity != nil { + activities = append(activities, *stream.activity) + } + } + return activities +} + +// appendEventLocked assigns the next seq, freezes the payload, stores the +// event, and fans it out to subscribers. +func (s *conversationStreamStore) appendEventLocked( + stream *conversationStream, + runID string, + eventType string, + payload map[string]any, + now time.Time, +) *ConversationEvent { + if payload == nil { + payload = make(map[string]any, 4) + } + stream.lastSeq++ + payload["conversation_id"] = stream.conversationID + payload["run_id"] = runID + payload["seq"] = stream.lastSeq + payload["type"] = eventType + event := &ConversationEvent{ + ConversationID: stream.conversationID, + RunID: runID, + Seq: stream.lastSeq, + Type: eventType, + Payload: payload, + ReceivedAt: now, + approxBytes: approxPayloadBytes(payload), + } + stream.events = append(stream.events, event) + stream.eventsBytes += event.approxBytes + stream.lastEventAt = now + stream.updatedAt = now + s.evictStreamLocked(stream, now) + s.publishLocked(stream, event) + return event +} + +// publishLocked delivers an event to every subscriber without blocking. A +// subscriber whose buffer is full is closed; the client resumes via +// re-subscribe with after_seq (the ring still holds the events). +func (s *conversationStreamStore) publishLocked(stream *conversationStream, event *ConversationEvent) { + for id, sub := range stream.subscribers { + if sub.closed { + continue + } + select { + case sub.ch <- event: + default: + sub.overflowed = true + sub.closed = true + close(sub.ch) + delete(stream.subscribers, id) + } + } +} + +func approxPayloadBytes(payload map[string]any) int { + total := 64 + for key, value := range payload { + total += len(key) + approxValueBytes(value, 2) + } + return total +} + +func approxValueBytes(value any, depth int) int { + switch v := value.(type) { + case string: + return len(v) + 8 + case map[string]any: + if depth <= 0 { + return 64 + } + total := 16 + for key, nested := range v { + total += len(key) + approxValueBytes(nested, depth-1) + } + return total + case []any: + if depth <= 0 { + return 64 + } + total := 16 + for _, nested := range v { + total += approxValueBytes(nested, depth-1) + } + return total + default: + return 16 + } +} + +func (stream *conversationStream) runFinishedRecently(runID string) bool { + for _, finished := range stream.finishedRuns { + if finished == runID { + return true + } + } + return false +} + +// runStartedLocked registers runID as the conversation's current run, +// superseding a still-active previous run. Idempotent per run. +func (s *conversationStreamStore) runStartedLocked( + stream *conversationStream, + runID string, + workdir string, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity != nil && stream.activity.RunID == runID { + switch stream.activity.State { + case RunActivityQueued: + // The gateway-accepted command actually started: append the + // run_started log event now. StartedSeq keeps covering the seeded + // user_message so the whole run stays replayable. + s.flushDeferredSeedsLocked(stream, runID, s.runRecordLocked(runID, stream.conversationID), now) + payload := map[string]any{} + if stream.activity.ClientRequestID != "" { + payload["client_request_id"] = stream.activity.ClientRequestID + } + if stream.workdir != "" { + payload["workdir"] = stream.workdir + } + s.appendEventLocked(stream, runID, StreamEventRunStarted, payload, now) + stream.activity.State = RunActivityRunning + stream.activity.UpdatedAt = now + s.publishActivityLocked(stream, now) + case RunActivityCancelling: + // A cancel is in flight; keep the cancelling state. + } + return + } + if stream.activity != nil && + (stream.activity.State == RunActivityRunning || stream.activity.State == RunActivityCancelling) { + // Supersession: the agent started a new run (e.g. a queued prompt + // auto-send) before the previous run's terminal signal arrived. + s.runFinishedLocked(stream, stream.activity.RunID, "completed", "", "", map[string]any{ + "reason": "superseded", + }, now) + } + if workdir = strings.TrimSpace(workdir); workdir != "" { + stream.workdir = workdir + } + record := s.runRecordLocked(runID, stream.conversationID) + s.flushDeferredSeedsLocked(stream, runID, record, now) + payload := map[string]any{} + if record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + if stream.workdir != "" { + payload["workdir"] = stream.workdir + } + startEvent := s.appendEventLocked(stream, runID, StreamEventRunStarted, payload, now) + startedSeq := startEvent.Seq + if record.firstSeededSeq > 0 && record.firstSeededSeq < startedSeq { + // The run's user_message was seeded before it started (e.g. it + // started through supersession while another run was active); the + // eviction guard must cover the seed too. + startedSeq = record.firstSeededSeq + } + stream.activity = &RunActivity{ + ConversationID: stream.conversationID, + RunID: runID, + ClientRequestID: record.clientRequestID, + State: RunActivityRunning, + StartedSeq: startedSeq, + Workdir: stream.workdir, + UpdatedAt: now, + } + s.publishActivityLocked(stream, now) +} + +// runFinishedLocked appends run_finished exactly once per run and clears the +// activity when the finished run is the current one. +func (s *conversationStreamStore) runFinishedLocked( + stream *conversationStream, + runID string, + status string, + errorCode string, + message string, + extra map[string]any, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity == nil || stream.activity.RunID != runID { + // Terminal signal for a run this stream never started (e.g. the + // gateway restarted mid-run). Synthesize the start so clients see a + // coherent pair, unless another run is currently active — then the + // stray terminal is recorded without touching the active run. + if stream.activity == nil { + s.runStartedLocked(stream, runID, "", now) + } + } + payload := map[string]any{ + "status": status, + } + if errorCode != "" { + payload["error_code"] = errorCode + } + if message != "" { + payload["message"] = message + } + for key, value := range extra { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + if record := s.runs[runID]; record != nil && record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + s.appendEventLocked(stream, runID, StreamEventRunFinished, payload, now) + stream.finishedRuns = append(stream.finishedRuns, runID) + if len(stream.finishedRuns) > conversationFinishedRunMemory { + evicted := stream.finishedRuns[0] + stream.finishedRuns = stream.finishedRuns[1:] + delete(s.runs, evicted) + } + if stream.latestSnapshot != nil && stream.latestSnapshot.RunID == runID { + stream.latestSnapshot = nil + } + if stream.activity != nil && stream.activity.RunID == runID { + stream.activity = nil + stream.runNeedsSnapshot = false + stream.snapshotDirty = false + s.publishActivityLocked(stream, now) + } +} + +// markRunQueuedLocked records that a run's command is pending in the gateway +// (accepted but not yet started). No log event — activity only. +func (s *conversationStreamStore) markRunQueuedLocked( + stream *conversationStream, + runID string, + clientRequestID string, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity != nil { + return + } + stream.activity = &RunActivity{ + ConversationID: stream.conversationID, + RunID: runID, + ClientRequestID: clientRequestID, + State: RunActivityQueued, + StartedSeq: stream.lastSeq + 1, + Workdir: stream.workdir, + UpdatedAt: now, + } + s.publishActivityLocked(stream, now) +} + +func (s *conversationStreamStore) runRecordLocked(runID string, conversationID string) *chatRunRecord { + record := s.runs[runID] + if record == nil { + record = &chatRunRecord{conversationID: conversationID} + s.runs[runID] = record + } else if record.conversationID == "" { + record.conversationID = conversationID + } + return record +} + +func (s *conversationStreamStore) publishActivityLocked(stream *conversationStream, now time.Time) { + event := ConversationActivityEvent{ + ConversationID: stream.conversationID, + Workdir: stream.workdir, + UpdatedAt: now, + } + if stream.activity != nil { + event.RunID = stream.activity.RunID + event.ClientRequestID = stream.activity.ClientRequestID + event.Running = true + event.State = stream.activity.State + if stream.activity.Workdir != "" { + event.Workdir = stream.activity.Workdir + } + } + s.activityHub.publish(event) +} + +// --- command lifecycle ----------------------------------------------------- + +// WatchChatCommand registers a watcher for pre-stream command outcomes +// (bound / queued_in_gui / failed). Call before StartChatCommand. +func (m *Manager) WatchChatCommand(runID string) (<-chan ChatCommandUpdate, func()) { + s := m.convStreams + runID = strings.TrimSpace(runID) + ch := make(chan ChatCommandUpdate, 4) + + s.mu.Lock() + s.commandWatchers[runID] = append(s.commandWatchers[runID], ch) + s.mu.Unlock() + + cleanup := func() { + s.mu.Lock() + defer s.mu.Unlock() + watchers := s.commandWatchers[runID] + for i, watcher := range watchers { + if watcher == ch { + s.commandWatchers[runID] = append(watchers[:i], watchers[i+1:]...) + // All sends happen under s.mu after a registration check, so + // closing here is safe and releases the forwarder goroutine. + close(ch) + break + } + } + if len(s.commandWatchers[runID]) == 0 { + delete(s.commandWatchers, runID) + } + } + return ch, cleanup +} + +func (s *conversationStreamStore) fireCommandUpdateLocked(update ChatCommandUpdate) { + for _, watcher := range s.commandWatchers[update.RunID] { + select { + case watcher <- update: + default: + } + } +} + +// ChatCommandStart is the accepted-command result returned to the transport. +type ChatCommandStart struct { + RunID string + ConversationID string + AcceptedSeq int64 +} + +// StartChatCommand registers a webui-issued chat command. For a known +// conversation the seeded payloads (rebased/user_message) are appended to the +// log immediately; for a draft conversation they are buffered until the first +// agent signal binds the run to a real conversation id. +func (m *Manager) StartChatCommand( + runID string, + conversationID string, + workdir string, + clientRequestID string, + seededPayloads []map[string]any, +) ChatCommandStart { + s := m.convStreams + runID = strings.TrimSpace(runID) + conversationID = strings.TrimSpace(conversationID) + workdir = strings.TrimSpace(workdir) + clientRequestID = strings.TrimSpace(clientRequestID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + if conversationID == "" { + s.pendingRuns[runID] = &pendingChatRun{ + runID: runID, + clientRequestID: clientRequestID, + workdir: workdir, + seeded: seededPayloads, + createdAt: now, + } + s.startReaper() + return ChatCommandStart{RunID: runID} + } + + stream := s.streamLocked(conversationID, now) + if workdir != "" { + stream.workdir = workdir + } + record := s.runRecordLocked(runID, conversationID) + record.clientRequestID = clientRequestID + + if stream.activity != nil { + // A run is already active: this command is almost certainly headed + // for the desktop prompt queue. Seeding the user_message into the log + // now would flash a bubble on every viewer until the queued_in_gui + // compensation removes it — defer the seeds until the run actually + // starts (or fails); if it parks in the GUI queue they are dropped + // and the agent's own echo becomes authoritative. + record.deferredSeeds = seededPayloads + return ChatCommandStart{ + RunID: runID, + ConversationID: conversationID, + AcceptedSeq: stream.lastSeq, + } + } + + // Mark queued before seeding so the activity's StartedSeq covers the + // seeded user_message — the whole run replays from one cursor. + s.markRunQueuedLocked(stream, runID, clientRequestID, now) + acceptedSeq := s.appendSeededPayloadsLocked(stream, runID, clientRequestID, seededPayloads, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(seededPayloads) + return ChatCommandStart{ + RunID: runID, + ConversationID: conversationID, + AcceptedSeq: acceptedSeq, + } +} + +// flushDeferredSeedsLocked appends seeds that were deferred because another +// run was active at accept time. Called right before the run's run_started +// event so the log keeps the normal [user_message, run_started, ...] shape. +func (s *conversationStreamStore) flushDeferredSeedsLocked( + stream *conversationStream, + runID string, + record *chatRunRecord, + now time.Time, +) { + if len(record.deferredSeeds) == 0 { + return + } + seeds := record.deferredSeeds + record.deferredSeeds = nil + s.appendSeededPayloadsLocked(stream, runID, record.clientRequestID, seeds, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(seeds) +} + +func (s *conversationStreamStore) appendSeededPayloadsLocked( + stream *conversationStream, + runID string, + clientRequestID string, + seededPayloads []map[string]any, + now time.Time, +) int64 { + acceptedSeq := stream.lastSeq + for _, payload := range seededPayloads { + if len(payload) == 0 { + continue + } + eventType, _ := payload["type"].(string) + if eventType == "" { + continue + } + cloned := make(map[string]any, len(payload)+5) + for key, value := range payload { + cloned[key] = value + } + if eventType == "user_message" && clientRequestID != "" { + cloned["client_request_id"] = clientRequestID + } + event := s.appendEventLocked(stream, runID, eventType, cloned, now) + acceptedSeq = event.Seq + if record := s.runs[runID]; record != nil && record.firstSeededSeq == 0 { + record.firstSeededSeq = event.Seq + } + } + return acceptedSeq +} + +func seededPayloadsIncludeUserMessage(seededPayloads []map[string]any) bool { + for _, payload := range seededPayloads { + if eventType, _ := payload["type"].(string); eventType == "user_message" { + return true + } + } + return false +} + +// FailChatCommand fails a command that never produced a bound run (agent +// unreachable, startup watchdog) or force-finishes its run when bound. +func (m *Manager) FailChatCommand(runID string, errorCode string, message string) { + s := m.convStreams + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + if pending := s.pendingRuns[runID]; pending != nil { + delete(s.pendingRuns, runID) + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: runID, + ClientRequestID: pending.clientRequestID, + Phase: "failed", + ErrorCode: errorCode, + Message: message, + }) + return + } + + record := s.runs[runID] + if record == nil || record.conversationID == "" { + return + } + stream := s.streams[record.conversationID] + if stream == nil { + return + } + // Seeds deferred at accept time surface now so the failure has its user + // message for context; runFinishedLocked follows with the error. + s.flushDeferredSeedsLocked(stream, runID, record, now) + s.runFinishedLocked(stream, runID, "failed", errorCode, message, nil, now) +} + +// ChatCommandSettled reports whether a command reached a state the startup +// watchdog must not interfere with: its run started, finished, or was parked +// in the desktop prompt queue. +func (m *Manager) ChatCommandSettled(runID string) bool { + s := m.convStreams + runID = strings.TrimSpace(runID) + s.mu.Lock() + defer s.mu.Unlock() + record := s.runs[runID] + if record == nil { + return false + } + if record.queuedInGUI { + return true + } + if record.conversationID == "" { + return false + } + stream := s.streams[record.conversationID] + if stream == nil { + return false + } + if stream.runFinishedRecently(runID) { + return true + } + return stream.activity != nil && + stream.activity.RunID == runID && + stream.activity.State != RunActivityQueued +} + +// MarkConversationCancelling flips the active run into the cancelling state +// and returns its run id for the caller's watchdog. The agent's real terminal +// signal wins; ForceFinishRun is the fallback. +func (m *Manager) MarkConversationCancelling(conversationID string, runID string) (string, bool) { + s := m.convStreams + conversationID = strings.TrimSpace(conversationID) + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + stream := s.streams[conversationID] + if stream == nil || stream.activity == nil { + return "", false + } + if runID != "" && stream.activity.RunID != runID { + return "", false + } + stream.activity.State = RunActivityCancelling + stream.activity.UpdatedAt = now + s.publishActivityLocked(stream, now) + return stream.activity.RunID, true +} + +// ForceFinishRun finishes a run from a gateway-side watchdog. No-op when the +// run already finished (exactly-once guard). +func (m *Manager) ForceFinishRun(runID string, status string, errorCode string, message string) { + s := m.convStreams + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + record := s.runs[runID] + if record == nil || record.conversationID == "" { + return + } + stream := s.streams[record.conversationID] + if stream == nil { + return + } + s.runFinishedLocked(stream, runID, status, errorCode, message, nil, now) +} + +// --- maintenance ----------------------------------------------------------- + +// onRuntimeStatus reconciles the desktop's run ledger with tracked +// activities: active reports vouch per run, finished reports adopt terminal +// signals the gateway missed, and a run absent from both is finalized once +// nothing has vouched for it within the grace window. Every vouch bumps +// activity.UpdatedAt, so its staleness measures continuous absence. +func (s *conversationStreamStore) onRuntimeStatus(event *gatewayv1.RuntimeStatusEvent, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + activeSet := make(map[string]bool, len(event.GetActiveRuns())) + for _, report := range event.GetActiveRuns() { + activeSet[report.GetRunId()] = true + } + finished := make(map[string]*gatewayv1.ChatRunReport, len(event.GetFinishedRuns())) + for _, report := range event.GetFinishedRuns() { + finished[report.GetRunId()] = report + } + + // Reconcile only tracked activities; finished reports never resurrect a + // stream for a run this store is not tracking. + for _, stream := range s.streams { + if stream.activity == nil { + continue + } + runID := stream.activity.RunID + if stream.activity.State == RunActivityQueued { + // The accepted-command startup watchdog owns the queued phase; + // the desktop may not know the run yet. + continue + } + if activeSet[runID] { + stream.activity.UpdatedAt = now + continue + } + if report, ok := finished[runID]; ok { + state := report.GetState() + errorCode := report.GetErrorCode() + switch state { + case "completed", "failed", "cancelled": + default: + state = "failed" + errorCode = "desktop_run_lost" + } + s.runFinishedLocked(stream, runID, state, errorCode, report.GetMessage(), + map[string]any{"reason": "desktop_reported"}, now) + continue + } + // Stream events vouch too: never finalize a run whose events are still + // flowing through the relay (mirrors the reaper's lastAlive logic). + eventsQuiet := stream.lastEventAt.IsZero() || + now.Sub(stream.lastEventAt) >= s.runReportLostTimeout + if eventsQuiet && now.Sub(stream.activity.UpdatedAt) >= s.runReportLostTimeout { + s.runFinishedLocked(stream, runID, "failed", "desktop_run_lost", + "The desktop runtime stopped reporting this run.", nil, now) + } + } +} + +func (s *conversationStreamStore) startReaper() { + s.reaperOnce.Do(func() { + interval := s.reaperInterval + if interval <= 0 { + interval = conversationReaperInterval + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + s.reap(time.Now()) + } + }() + }) +} + +func (s *conversationStreamStore) reap(now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + online := s.isOnline != nil && s.isOnline() + + for conversationID, stream := range s.streams { + s.evictStreamLocked(stream, now) + + if stream.activity != nil { + // A run is stale only when NOTHING vouches for it: no stream + // events and no activity transition/report-vouch within the + // timeout (onRuntimeStatus bumps UpdatedAt for reported runs). + lastAlive := stream.lastEventAt + if stream.activity.UpdatedAt.After(lastAlive) { + lastAlive = stream.activity.UpdatedAt + } + if online { + if !lastAlive.IsZero() && now.Sub(lastAlive) > s.staleRunTimeout { + s.runFinishedLocked(stream, stream.activity.RunID, "failed", "stale_run", + "The desktop runtime stopped reporting this run.", nil, now) + } + } else if !lastAlive.IsZero() && now.Sub(lastAlive) > s.offlineRunTimeout { + s.runFinishedLocked(stream, stream.activity.RunID, "failed", "agent_offline", + "The desktop agent went offline during this run.", nil, now) + } + } + + if stream.activity == nil && + len(stream.subscribers) == 0 && + now.Sub(stream.updatedAt) > s.idleRetention { + for _, finished := range stream.finishedRuns { + delete(s.runs, finished) + } + delete(s.streams, conversationID) + } + } + + for runID, pending := range s.pendingRuns { + if now.Sub(pending.createdAt) > pendingChatRunRetention { + delete(s.pendingRuns, runID) + } + } +} diff --git a/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go b/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go new file mode 100644 index 000000000..caec3465d --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func runReport(runID string, conversationID string, state string) *gatewayv1.ChatRunReport { + return &gatewayv1.ChatRunReport{ + RunId: runID, + ConversationId: conversationID, + State: state, + } +} + +func runsReport( + active []*gatewayv1.ChatRunReport, + finished []*gatewayv1.ChatRunReport, +) *gatewayv1.RuntimeStatusEvent { + return &gatewayv1.RuntimeStatusEvent{ + ActiveRunCount: uint32(len(active)), + ActiveRuns: active, + FinishedRuns: finished, + } +} + +func lastEvent(t *testing.T, m *Manager, conversationID string) *ConversationEvent { + t.Helper() + sub := m.SubscribeConversationStream(conversationID, 0, "") + sub.Cleanup() + if len(sub.Events) == 0 { + t.Fatalf("no events for %s", conversationID) + } + return sub.Events[len(sub.Events)-1] +} + +// A terminal the gateway never received (lost desktop signal) is adopted from +// the desktop's finished_runs report with its real final state. +func TestRunReportAdoptsMissedTerminal(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + + m.convStreams.onRuntimeStatus(runsReport(nil, []*gatewayv1.ChatRunReport{ + runReport("run-1", "conv-1", "completed"), + }), time.Now()) + + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || last.Payload["status"] != "completed" { + t.Fatalf("adopted terminal = %s %#v, want run_finished/completed", last.Type, last.Payload) + } + if last.Payload["reason"] != "desktop_reported" { + t.Fatalf("adopted terminal reason = %#v, want desktop_reported", last.Payload["reason"]) + } + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("activity not cleared after adopted terminal, activities=%d", len(activities)) + } + + // A finished report with an unknown state is not trusted verbatim: the + // run fails with desktop_run_lost instead. + m2 := NewManager() + m2.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + m2.convStreams.onRuntimeStatus(runsReport(nil, []*gatewayv1.ChatRunReport{ + runReport("run-2", "conv-2", "exploded"), + }), time.Now()) + last2 := lastEvent(t, m2, "conv-2") + if last2.Type != StreamEventRunFinished || + last2.Payload["status"] != "failed" || + last2.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("invalid-state terminal = %s %#v, want failed/desktop_run_lost", last2.Type, last2.Payload) + } +} + +// A run absent from the desktop's reports survives the grace window (measured +// from the last vouch/transition), then is finalized as lost; a vouch before +// expiry restarts the window. +func TestRunReportFinalizesLostRunAfterGrace(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + t0 := time.Now() + empty := runsReport(nil, nil) + + m.convStreams.onRuntimeStatus(empty, t0.Add(8*time.Second)) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("run finalized below grace, activities=%d", len(activities)) + } + + m.convStreams.onRuntimeStatus(empty, t0.Add(16*time.Second)) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("lost run not finalized after grace, activities=%d", len(activities)) + } + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || + last.Payload["status"] != "failed" || + last.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("lost finish tail = %s %#v, want failed/desktop_run_lost", last.Type, last.Payload) + } + + // Reported active again before grace expiry: the run survives and the + // absence window restarts from the vouch. + m2 := NewManager() + m2.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + t1 := time.Now() + m2.convStreams.onRuntimeStatus(runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-2", "conv-2", "running"), + }, nil), t1.Add(8*time.Second)) + m2.convStreams.onRuntimeStatus(runsReport(nil, nil), t1.Add(20*time.Second)) + if activities := m2.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("grace window must restart after a vouch, activities=%d", len(activities)) + } +} + +// Queued runs belong to the accepted-command startup watchdog; the desktop may +// not know them yet, so reconcile never finalizes them. +func TestRunReportSkipsQueuedRuns(t *testing.T) { + m := NewManager() + m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + + t0 := time.Now() + m.convStreams.onRuntimeStatus(runsReport(nil, nil), t0) + m.convStreams.onRuntimeStatus(runsReport(nil, nil), t0.Add(time.Hour)) + + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].State != RunActivityQueued { + t.Fatalf("queued run must survive reconcile, activities=%#v", activities) + } +} + +// Liveness is per run: a vouched run keeps streaming while an absent run in +// another conversation is finalized at grace. +func TestRunReportPerConversationLiveness(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-a")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-b")) + t0 := time.Now() + vouchA := runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-a", "conv-a", "running"), + }, nil) + + m.convStreams.onRuntimeStatus(vouchA, t0) + m.convStreams.onRuntimeStatus(vouchA, t0.Add(16*time.Second)) + + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-a" { + t.Fatalf("vouched run must outlive the lost one, activities=%#v", activities) + } + last := lastEvent(t, m, "conv-b") + if last.Type != StreamEventRunFinished || last.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("conv-b tail = %s %#v, want failed/desktop_run_lost", last.Type, last.Payload) + } +} + +// The reaper is per run too: a report vouching only for another conversation +// must not shield a run the desktop stopped vouching for. +func TestReaperSparesOnlyVouchedRuns(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = 10 * time.Millisecond + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(20 * time.Millisecond) + + m.ingestChatControl("run-other", startedControl("run-other", "conv-other")) + m.convStreams.onRuntimeStatus(runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-other", "conv-other", "running"), + }, nil), time.Now()) + + m.convStreams.reap(time.Now()) + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-other" { + t.Fatalf("unvouched run must be reaped, activities=%#v", activities) + } +} + +// Offline runs are not immortal: past offlineRunTimeout they finalize as +// agent_offline; below it they are kept. +func TestReaperFinalizesRunsAfterOfflineTimeout(t *testing.T) { + m := NewManager() // no session: isOnline() == false + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + t0 := time.Now() + + m.convStreams.reap(t0.Add(29 * time.Minute)) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("run below offline timeout must be kept, activities=%d", len(activities)) + } + + m.convStreams.reap(t0.Add(31 * time.Minute)) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("run beyond offline timeout must be finalized, activities=%d", len(activities)) + } + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || + last.Payload["status"] != "failed" || + last.Payload["error_code"] != "agent_offline" { + t.Fatalf("offline finish tail = %s %#v, want failed/agent_offline", last.Type, last.Payload) + } +} diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go new file mode 100644 index 000000000..acd66e93d --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -0,0 +1,936 @@ +package session + +import ( + "encoding/json" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func tokenEvent(conversationID string, text string) *gatewayv1.ChatEvent { + data, _ := json.Marshal(map[string]any{"text": text}) + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: string(data), + } +} + +func doneEvent(conversationID string) *gatewayv1.ChatEvent { + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: `{"title":"Final title"}`, + } +} + +func startedControl(runID string, conversationID string) *gatewayv1.ChatControlEvent { + return &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "started", + State: "running", + } +} + +func completedControl(runID string, conversationID string) *gatewayv1.ChatControlEvent { + return &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "completed", + State: "completed", + } +} + +func drainEvents(t *testing.T, ch <-chan *ConversationEvent, count int) []*ConversationEvent { + t.Helper() + events := make([]*ConversationEvent, 0, count) + timeout := time.After(2 * time.Second) + for len(events) < count { + select { + case event, ok := <-ch: + if !ok { + t.Fatalf("event channel closed after %d events, want %d", len(events), count) + } + events = append(events, event) + case <-timeout: + t.Fatalf("timed out after %d events, want %d", len(events), count) + } + } + return events +} + +func eventTypes(events []*ConversationEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + types = append(types, event.Type) + } + return types +} + +func TestConversationStreamSeqMonotonicAcrossRuns(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hello")) + m.ingestChatEvent("run-1", doneEvent("conv-1")) + m.ingestChatControl("run-2", startedControl("run-2", "conv-1")) + m.ingestChatEvent("run-2", tokenEvent("conv-1", "again")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + var lastSeq int64 + for _, event := range sub.Events { + if event.Seq <= lastSeq { + t.Fatalf("seq not monotonic: %d after %d (types %v)", event.Seq, lastSeq, eventTypes(sub.Events)) + } + lastSeq = event.Seq + } + types := eventTypes(sub.Events) + want := []string{"run_started", "token", "run_finished", "run_started", "token"} + if len(types) != len(want) { + t.Fatalf("replayed types = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replayed types = %v, want %v", types, want) + } + } + if sub.Activity == nil || sub.Activity.RunID != "run-2" || sub.Activity.State != RunActivityRunning { + t.Fatalf("activity = %#v, want running run-2", sub.Activity) + } +} + +func TestRunFinishedExactlyOnceForDuplicateTerminals(t *testing.T) { + cases := []struct { + name string + first func(m *Manager) + second func(m *Manager) + }{ + { + name: "done event then completed control", + first: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + second: func(m *Manager) { m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) }, + }, + { + name: "completed control then terminal snapshot", + first: func(m *Manager) { m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) }, + second: func(m *Manager) { + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", ConversationId: "conv-1", State: "completed", + }) + }, + }, + { + name: "terminal snapshot then done event", + first: func(m *Manager) { + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", ConversationId: "conv-1", State: "cancelled", + }) + }, + second: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + }, + { + name: "force finish then late done", + first: func(m *Manager) { m.ForceFinishRun("run-1", "cancelled", "", "") }, + second: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hello")) + tc.first(m) + tc.second(m) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + finished := 0 + for _, event := range sub.Events { + if event.Type == StreamEventRunFinished { + finished++ + } + } + if finished != 1 { + t.Fatalf("run_finished count = %d, want 1 (types %v)", finished, eventTypes(sub.Events)) + } + if sub.Activity != nil { + t.Fatalf("activity should be cleared, got %#v", sub.Activity) + } + }) + } +} + +func TestSupersessionFinishesPreviousRunFirst(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "a")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + // A queued prompt auto-send: run-b starts before run-a's terminal arrives. + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "b")) + + live := drainEvents(t, sub.EventCh, 3) + types := eventTypes(live) + if types[0] != StreamEventRunFinished || live[0].RunID != "run-a" { + t.Fatalf("first live event = %s/%s, want run_finished/run-a", types[0], live[0].RunID) + } + if live[0].Payload["reason"] != "superseded" { + t.Fatalf("superseded reason missing: %#v", live[0].Payload) + } + if types[1] != StreamEventRunStarted || live[1].RunID != "run-b" { + t.Fatalf("second live event = %s/%s, want run_started/run-b", types[1], live[1].RunID) + } + if types[2] != "token" || live[2].RunID != "run-b" { + t.Fatalf("third live event = %s/%s, want token/run-b", types[2], live[2].RunID) + } + + // The late terminal for run-a is swallowed. + m.ingestChatControl("run-a", completedControl("run-a", "conv-1")) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "b2")) + next := drainEvents(t, sub.EventCh, 1) + if next[0].Type != "token" || next[0].RunID != "run-b" { + t.Fatalf("late terminal leaked: got %s/%s", next[0].Type, next[0].RunID) + } +} + +func TestSubscribeResumeAndResetSemantics(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "one")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "two")) + + base := m.SubscribeConversationStream("conv-1", 0, "") + base.Cleanup() + epoch := base.StreamEpoch + latest := base.LatestSeq + + resume := m.SubscribeConversationStream("conv-1", latest-1, epoch) + resume.Cleanup() + if resume.Reset { + t.Fatalf("resume within buffer should not reset") + } + if len(resume.Events) != 1 || resume.Events[0].Seq != latest { + t.Fatalf("resume replay = %v", eventTypes(resume.Events)) + } + + ahead := m.SubscribeConversationStream("conv-1", latest+100, epoch) + ahead.Cleanup() + if !ahead.Reset || len(ahead.Events) != int(latest) { + t.Fatalf("client ahead of gateway must reset with full replay, got reset=%v events=%d", ahead.Reset, len(ahead.Events)) + } + + wrongEpoch := m.SubscribeConversationStream("conv-1", latest, "different-epoch") + wrongEpoch.Cleanup() + if !wrongEpoch.Reset { + t.Fatalf("epoch mismatch must reset") + } + + // Gap: force eviction of the early events. + m.convStreams.mu.Lock() + stream := m.convStreams.streams["conv-1"] + stream.evictedThroughSeq = 2 + m.convStreams.mu.Unlock() + gap := m.SubscribeConversationStream("conv-1", 1, epoch) + gap.Cleanup() + if !gap.Reset { + t.Fatalf("resume below evicted floor must reset") + } +} + +func TestStartChatCommandSeedsAndAgentEchoSwallowed(t *testing.T) { + m := NewManager() + start := m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + if start.AcceptedSeq <= 0 { + t.Fatalf("accepted seq = %d, want > 0", start.AcceptedSeq) + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if len(sub.Events) != 1 || sub.Events[0].Type != "user_message" { + t.Fatalf("seeded replay = %v", eventTypes(sub.Events)) + } + if sub.Events[0].Payload["client_request_id"] != "client-1" { + t.Fatalf("seeded user_message missing client_request_id: %#v", sub.Events[0].Payload) + } + if sub.Activity == nil || sub.Activity.State != RunActivityQueued { + t.Fatalf("activity = %#v, want queued", sub.Activity) + } + + // Agent starts the run and echoes the user message: the echo is swallowed. + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + userEcho, _ := json.Marshal(map[string]any{"message": "hello"}) + m.ingestChatEvent("run-1", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(userEcho), + }) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hi")) + + live := drainEvents(t, sub.EventCh, 2) + types := eventTypes(live) + if types[0] != StreamEventRunStarted || types[1] != "token" { + t.Fatalf("live types = %v, want [run_started token]", types) + } +} + +func TestPendingRunBindsOnFirstAgentSignal(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-1") + defer cleanupWatch() + + start := m.StartChatCommand("run-1", "", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + if start.ConversationID != "" || start.AcceptedSeq != 0 { + t.Fatalf("pending start = %#v", start) + } + + m.ingestChatControl("run-1", startedControl("run-1", "conv-new")) + + select { + case update := <-updates: + if update.Phase != "bound" || update.ConversationID != "conv-new" || update.ClientRequestID != "client-1" { + t.Fatalf("bound update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no bound update") + } + + sub := m.SubscribeConversationStream("conv-new", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{"user_message", "run_started"} + if len(types) != len(want) || types[0] != want[0] || types[1] != want[1] { + t.Fatalf("bound replay = %v, want %v", types, want) + } +} + +func TestQueuedInGUICompensatesSeededEntries(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-1") + defer cleanupWatch() + + m.StartChatCommand("run-1", "conv-1", "", "client-1", []map[string]any{ + {"type": "user_message", "message": "queued prompt"}, + }) + m.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "queued_in_gui", + }) + + select { + case update := <-updates: + if update.Phase != "queued_in_gui" { + t.Fatalf("update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no queued_in_gui update") + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + if len(types) != 2 || types[0] != "user_message" || types[1] != StreamEventRunQueued { + t.Fatalf("replay = %v, want [user_message run_queued]", types) + } + if sub.Activity != nil { + t.Fatalf("queued_in_gui must clear activity, got %#v", sub.Activity) + } + if m.ChatCommandSettled("run-1") != true { + t.Fatalf("queued_in_gui command must count as settled") + } + + // Later auto-send: the agent echo must now pass through. + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + userEcho, _ := json.Marshal(map[string]any{"message": "queued prompt"}) + m.ingestChatEvent("run-1", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(userEcho), + }) + live := drainEvents(t, sub.EventCh, 2) + types = eventTypes(live) + if types[0] != StreamEventRunStarted || types[1] != "user_message" { + t.Fatalf("auto-send live types = %v, want [run_started user_message]", types) + } +} + +func TestFailChatCommandPendingAndBound(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-pending") + defer cleanupWatch() + m.StartChatCommand("run-pending", "", "", "client-1", nil) + m.FailChatCommand("run-pending", "desktop_runtime_unavailable", "agent offline") + select { + case update := <-updates: + if update.Phase != "failed" || update.ErrorCode != "desktop_runtime_unavailable" { + t.Fatalf("failed update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no failed update") + } + + m.StartChatCommand("run-bound", "conv-1", "", "client-2", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + m.FailChatCommand("run-bound", "startup_timeout", "did not start") + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + last := sub.Events[len(sub.Events)-1] + if last.Type != StreamEventRunFinished || last.Payload["status"] != "failed" { + t.Fatalf("bound failure tail = %s %#v", last.Type, last.Payload) + } + if !m.ChatCommandSettled("run-bound") { + t.Fatalf("failed command should be settled") + } +} + +func TestActivityHubCarriesRunIDs(t *testing.T) { + m := NewManager() + activity, cleanup := m.SubscribeChatActivity() + defer cleanup() + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", doneEvent("conv-1")) + + running := <-activity + if !running.Running || running.RunID != "run-1" || running.State != RunActivityRunning { + t.Fatalf("running activity = %#v", running) + } + idle := <-activity + if idle.Running || idle.ConversationID != "conv-1" { + t.Fatalf("idle activity = %#v", idle) + } + + // A late subscriber replays current activity. + m.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + late, lateCleanup := m.SubscribeChatActivity() + defer lateCleanup() + replayed := <-late + if replayed.ConversationID != "conv-2" || replayed.RunID != "run-2" || !replayed.Running { + t.Fatalf("late replay = %#v", replayed) + } +} + +func TestEvictionProtectsActiveRunUntilHardCap(t *testing.T) { + m := NewManager() + m.convStreams.eventRetention = time.Millisecond + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "one")) + time.Sleep(5 * time.Millisecond) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "two")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + sub.Cleanup() + if len(sub.Events) != 3 { + t.Fatalf("active run events must survive retention, got %v", eventTypes(sub.Events)) + } + + // After the run finishes, retention applies again. + m.ingestChatEvent("run-1", doneEvent("conv-1")) + time.Sleep(5 * time.Millisecond) + m.convStreams.mu.Lock() + stream := m.convStreams.streams["conv-1"] + m.convStreams.evictStreamLocked(stream, time.Now()) + remaining := len(stream.events) + evictedThrough := stream.evictedThroughSeq + m.convStreams.mu.Unlock() + if remaining != 0 || evictedThrough == 0 { + t.Fatalf("idle stream should evict all expired events, remaining=%d evictedThrough=%d", remaining, evictedThrough) + } + + // Hard cap evicts even active-run events and flags truncation via the + // evicted floor so subscribers get a reset. + m2 := NewManager() + m2.convStreams.maxEvents = 4 + m2.ingestChatControl("run-1", startedControl("run-1", "conv-x")) + for i := 0; i < 10; i++ { + m2.ingestChatEvent("run-1", tokenEvent("conv-x", "t")) + } + m2.convStreams.mu.Lock() + streamX := m2.convStreams.streams["conv-x"] + if len(streamX.events) > 4 { + m2.convStreams.mu.Unlock() + t.Fatalf("hard cap not enforced: %d events", len(streamX.events)) + } + if streamX.evictedThroughSeq == 0 { + m2.convStreams.mu.Unlock() + t.Fatalf("evictedThroughSeq not advanced under hard cap") + } + m2.convStreams.mu.Unlock() +} + +func TestReaperForceFinishesStaleRunsOnlyWhenOnline(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = time.Millisecond + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(5 * time.Millisecond) + + // Agent offline: the run is left alone. + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("offline reap must not finish runs, activities=%d", len(activities)) + } + + // Agent online: the silent run is force-finished. + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("online reap must finish stale runs, activities=%d", len(activities)) + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + sub.Cleanup() + last := sub.Events[len(sub.Events)-1] + if last.Type != StreamEventRunFinished || last.Payload["error_code"] != "stale_run" { + t.Fatalf("stale finish tail = %s %#v", last.Type, last.Payload) + } +} + +func TestCancellingStateAndWatchdog(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + + runID, ok := m.MarkConversationCancelling("conv-1", "") + if !ok || runID != "run-1" { + t.Fatalf("cancelling = %q %v", runID, ok) + } + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if sub.Activity == nil || sub.Activity.State != RunActivityCancelling { + t.Fatalf("activity = %#v, want cancelling", sub.Activity) + } + + // The agent's real terminal wins over the watchdog. + m.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", ConversationId: "conv-1", Type: "cancelled", State: "cancelled", + }) + m.ForceFinishRun("run-1", "cancelled", "cancel_timeout", "watchdog") + + finished := 0 + for _, event := range drainEvents(t, sub.EventCh, 1) { + if event.Type == StreamEventRunFinished { + finished++ + if event.Payload["error_code"] == "cancel_timeout" { + t.Fatalf("watchdog overrode the agent terminal: %#v", event.Payload) + } + } + } + if finished != 1 { + t.Fatalf("run_finished count = %d", finished) + } +} + +func TestGatewayRestartSnapshotRebuildsStream(t *testing.T) { + // A fresh manager simulates a restarted gateway: the first thing it sees + // for the conversation is a runtime snapshot of an in-flight run. + m := NewManager() + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", + ConversationId: "conv-1", + State: "running", + Revision: 7, + EntriesJson: `[{"kind":"assistant","text":"partial"}]`, + ToolStatus: "Vibing", + }) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if sub.Activity == nil || sub.Activity.RunID != "run-1" { + t.Fatalf("activity = %#v", sub.Activity) + } + if sub.Activity.ToolStatus != "Vibing" { + t.Fatalf("tool status = %q", sub.Activity.ToolStatus) + } + if sub.Snapshot == nil || sub.Snapshot.Revision != 7 { + t.Fatalf("late joiner must get the snapshot, got %#v", sub.Snapshot) + } + + // Live continuation streams normally afterwards. + m.ingestChatEvent("run-1", tokenEvent("conv-1", "more")) + live := drainEvents(t, sub.EventCh, 1) + if live[0].Type != "token" { + t.Fatalf("live continuation = %v", eventTypes(live)) + } +} + +func TestSubscriberOverflowClosesAndResumes(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + for i := 0; i < conversationSubscriberBuffer+8; i++ { + m.ingestChatEvent("run-1", tokenEvent("conv-1", "x")) + } + + deadline := time.After(2 * time.Second) + closed := false + received := 0 + for !closed { + select { + case _, ok := <-sub.EventCh: + if !ok { + closed = true + break + } + received++ + if received > conversationSubscriberBuffer+8 { + t.Fatalf("received more events than sent") + } + case <-deadline: + t.Fatalf("subscriber channel never closed on overflow") + } + } + if !sub.Overflowed() { + t.Fatalf("overflow flag not set") + } + + // Resume from the last seen seq replays the tail without loss. + resume := m.SubscribeConversationStream("conv-1", int64(received)+1, sub.StreamEpoch) + resume.Cleanup() + if resume.Reset { + t.Fatalf("resume after overflow should not reset while buffer covers the gap") + } + total := received + len(resume.Events) + if total < conversationSubscriberBuffer+8 { + t.Fatalf("lost events across overflow: saw %d", total) + } +} + +func TestReaperSparesSilentRunsWhileReportsVouch(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = 10 * time.Millisecond + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(20 * time.Millisecond) + + // A silent long tool call: no events, but the run report vouches for it. + m.convStreams.onRuntimeStatus(&gatewayv1.RuntimeStatusEvent{ + ActiveRuns: []*gatewayv1.ChatRunReport{ + {RunId: "run-1", ConversationId: "conv-1", State: "running"}, + }, + }, time.Now()) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("vouched silent run must be spared, activities=%d", len(activities)) + } + + // Once the reports stop vouching for it, the run is reaped after the + // timeout elapses again. + time.Sleep(20 * time.Millisecond) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("stale run must be reaped once vouching stops, activities=%d", len(activities)) + } +} + +func TestSupersessionKeepsSeededUserMessageReplayable(t *testing.T) { + m := NewManager() + m.convStreams.eventRetention = time.Millisecond + + // Run A streams while a webui command for the same conversation is + // accepted and seeded; B later starts via supersession. + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "seeded prompt"}, + }) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "working")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + + // Age everything past retention, then trigger eviction: run B's activity + // window must still protect the seeded user_message. + time.Sleep(5 * time.Millisecond) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + hasSeed := false + for _, event := range sub.Events { + if event.Type == "user_message" && event.RunID == "run-b" { + hasSeed = true + } + } + if !hasSeed { + t.Fatalf("seeded user_message evicted despite active run: %v", eventTypes(sub.Events)) + } +} + +func TestWatchChatCommandCleanupClosesChannel(t *testing.T) { + m := NewManager() + updates, cleanup := m.WatchChatCommand("run-1") + cleanup() + select { + case _, ok := <-updates: + if ok { + t.Fatalf("expected closed channel, got value") + } + case <-time.After(time.Second): + t.Fatalf("watch channel not closed by cleanup") + } +} + +func TestSeedsDeferredWhileAnotherRunIsActive(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "streaming")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + // Command accepted mid-run: nothing may reach the log yet — a seeded + // user_message here would flash a bubble until queued_in_gui compensates. + start := m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "queued while busy"}, + }) + if start.AcceptedSeq != sub.LatestSeq { + t.Fatalf("deferred accept must not append events: acceptedSeq=%d latest=%d", start.AcceptedSeq, sub.LatestSeq) + } + + // The desktop parks it: still nothing in the log (no run_queued needed). + m.ingestChatControl("run-b", &gatewayv1.ChatControlEvent{ + RequestId: "run-b", ConversationId: "conv-1", Type: "queued_in_gui", + }) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "more")) + live := drainEvents(t, sub.EventCh, 1) + if live[0].Type != "token" { + t.Fatalf("expected only run-a token after deferred accept + park, got %v", eventTypes(live)) + } + + // The queued item eventually auto-sends: the agent echo is authoritative. + m.ingestChatEvent("run-a", doneEvent("conv-1")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + echo, _ := json.Marshal(map[string]any{"message": "queued while busy"}) + m.ingestChatEvent("run-b", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, ConversationId: "conv-1", Data: string(echo), + }) + tail := drainEvents(t, sub.EventCh, 3) + types := eventTypes(tail) + if types[0] != StreamEventRunFinished || types[1] != StreamEventRunStarted || types[2] != "user_message" { + t.Fatalf("auto-send tail = %v, want [run_finished run_started user_message]", types) + } +} + +func TestDeferredSeedsFlushWhenRunStartsDirectly(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + + m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "interrupt prompt"}, + }) + + // The desktop runs the command immediately (interrupt policy): the + // deferred seeds surface right before run_started, and the agent's echo + // is swallowed as usual. + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + echo, _ := json.Marshal(map[string]any{"message": "interrupt prompt"}) + m.ingestChatEvent("run-b", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, ConversationId: "conv-1", Data: string(echo), + }) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{"run_started", "run_finished", "user_message", "run_started", "token"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } + userMessages := 0 + for _, event := range sub.Events { + if event.Type == "user_message" { + userMessages++ + if event.Payload["client_request_id"] != "client-b" { + t.Fatalf("seeded user_message missing client_request_id: %#v", event.Payload) + } + } + } + if userMessages != 1 { + t.Fatalf("user_message count = %d, want 1 (echo swallowed)", userMessages) + } +} + +func editResendUserMessageEvent(conversationID string, message string, ref map[string]any) *gatewayv1.ChatEvent { + payload := map[string]any{"message": message} + if ref != nil { + payload["base_message_ref"] = ref + payload["reason"] = "edit_resend" + } + data, _ := json.Marshal(payload) + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: conversationID, + Data: string(data), + } +} + +func testBaseMessageRef() map[string]any { + return map[string]any{ + "segment_index": 0, + "message_index": 2, + "segment_id": "seg-1", + "message_id": "msg-2", + "role": "user", + "content_hash": "hash-2", + } +} + +func countEventType(events []*ConversationEvent, eventType string) int { + count := 0 + for _, event := range events { + if event.Type == eventType { + count++ + } + } + return count +} + +// GUI-local edit-resend, "started" control first (the usual desktop order): +// the ref-bearing user_message seeds one rebased event after run_started. +func TestGUIEditResendSeedsRebasedAfterRunStarted(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{StreamEventRunStarted, StreamEventRebased, "user_message", "token"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } + rebased := sub.Events[1] + ref, ok := rebased.Payload["base_message_ref"].(map[string]any) + if !ok || ref["message_id"] != "msg-2" || ref["content_hash"] != "hash-2" { + t.Fatalf("rebased base_message_ref = %#v", rebased.Payload["base_message_ref"]) + } + if rebased.Payload["reason"] != "edit_resend" { + t.Fatalf("rebased reason = %#v, want edit_resend", rebased.Payload["reason"]) + } +} + +// No prior control signal: the rebased seed still lands, before the +// synthesized run_started. +func TestGUIEditResendSeedsRebasedBeforeRunStarted(t *testing.T) { + m := NewManager() + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{StreamEventRebased, StreamEventRunStarted, "user_message"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } +} + +// A reconnect replay redelivers the same ref-bearing user_message: exactly +// one rebased event is seeded for the run. +func TestGUIEditResendRebasedSeededOnce(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 1 { + t.Fatalf("rebased count = %d (types %v), want 1", got, eventTypes(sub.Events)) + } +} + +// Plain sends (no ref, a null ref — the desktop bridge always serializes the +// key, as null when unset — or an empty ref) must not seed a truncation. +func TestUserMessageWithoutRefSeedsNoRebased(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "plain prompt", nil)) + + nullRef, _ := json.Marshal(map[string]any{ + "message": "null ref prompt", + "base_message_ref": nil, + }) + m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) + m.ingestChatControl("run-2", startedControl("run-2", "conv-1")) + m.ingestChatEvent("run-2", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(nullRef), + }) + + emptyRef, _ := json.Marshal(map[string]any{ + "message": "empty ref prompt", + "base_message_ref": map[string]any{"message_id": "", "content_hash": " "}, + }) + m.ingestChatControl("run-2", completedControl("run-2", "conv-1")) + m.ingestChatControl("run-3", startedControl("run-3", "conv-1")) + m.ingestChatEvent("run-3", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(emptyRef), + }) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 0 { + t.Fatalf("rebased count = %d (types %v), want 0", got, eventTypes(sub.Events)) + } +} + +// A webui-initiated edit_resend already seeds its rebased at accept time; +// the agent's ref-bearing echo is swallowed and must not seed a second one. +func TestWebuiEditResendEchoSeedsNoSecondRebased(t *testing.T) { + m := NewManager() + ref := testBaseMessageRef() + m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": StreamEventRebased, "base_message_ref": ref, "reason": "edit_resend"}, + {"type": "user_message", "message": "edited prompt", "base_message_ref": ref, "reason": "edit_resend"}, + }) + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", ref)) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 1 { + t.Fatalf("rebased count = %d (types %v), want 1", got, eventTypes(sub.Events)) + } + if got := countEventType(sub.Events, "user_message"); got != 1 { + t.Fatalf("user_message count = %d (types %v), want 1 (echo swallowed)", got, eventTypes(sub.Events)) + } +} diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index 2e1db0cfa..f83efb6c7 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -9,20 +9,13 @@ import ( ) var ErrAgentOffline = errors.New("agent offline") -var ErrChatRunNotFound = errors.New("chat run not found") var ErrTunnelNotFound = errors.New("tunnel not found") var ErrTunnelExpired = errors.New("tunnel expired") var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") -var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") +var ErrCommandQueueFull = errors.New("command queue full") +var ErrCommandQueueTimeout = errors.New("command queue timeout: agent did not reconnect") const ( - maxBufferedChatRunEvents = 50000 - chatRunDoneRetention = time.Hour - chatRunStartRetention = 5 * time.Minute - chatRunStaleRetention = 12 * time.Hour - - agentDisconnectedChatRunMessage = "Desktop agent disconnected. Please retry." - chatRuntimeReadyTTL = 15 * time.Second agentSessionHeartbeatTTL = 90 * time.Second defaultRuntimeReadyState = "ready" @@ -35,10 +28,12 @@ type AuthSnapshot struct { } type Manager struct { - registry *sessionRegistry - syncHub *syncHub - chatStore *chatRunStore - tunnels *tunnelStore + registry *sessionRegistry + syncHub *syncHub + convStreams *conversationStreamStore + tunnels *tunnelRuntime + cmdQueue *commandQueue + workspaceHub *workspaceActivityHub } type AgentSession struct { @@ -49,6 +44,7 @@ type AgentSession struct { LastPing time.Time toAgent chan *OutboundEnvelope + pingCh chan *gatewayv1.GatewayEnvelope done chan struct{} closeOnce sync.Once @@ -64,82 +60,6 @@ type agentStream struct { closeOnce sync.Once } -type ChatBroadcastEvent struct { - RequestID string - Event *gatewayv1.ChatEvent - Control *gatewayv1.ChatControlEvent - Payload map[string]any - Seq int64 - Workdir string -} - -type ChatRunSnapshot struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - FirstSeq int64 - LatestSeq int64 - RunEpoch int64 - State string - ErrorCode string - Done bool -} - -type ActiveChatRunSummary struct { - ConversationID string - RequestID string - Workdir string - FirstSeq int64 - LatestSeq int64 - RunEpoch int64 - UpdatedAt int64 -} - -const ( - ChatRunStateQueued = "queued" - ChatRunStateDelivered = "delivered" - ChatRunStateClaimed = "claimed" - ChatRunStateStarting = "starting" - ChatRunStateDesktopQueued = "desktop_queued" - ChatRunStateRunning = "running" - ChatRunStateCompleted = "completed" - ChatRunStateFailed = "failed" - ChatRunStateCancelled = "cancelled" -) - -type chatRun struct { - requestID string - conversationID string - clientRequestID string - workdir string - sessionEpoch uint64 - runEpoch int64 - events []*ChatBroadcastEvent - nextSeq int64 - state string - errorCode string - accepted bool - started bool - firstDeltaLogged bool - done bool - updatedAt time.Time - expiresAt time.Time - subscribers map[int]*chatRunSubscriber -} - -type activeHistoryRun struct { - conversationID string - workdir string - updatedAt time.Time -} - -type chatRunSubscriber struct { - ch chan *ChatBroadcastEvent - done chan struct{} - closeOnce sync.Once -} - type Status struct { Online bool `json:"online"` AgentReady bool `json:"agent_ready"` @@ -157,21 +77,15 @@ type Status struct { } func NewManager() *Manager { - return &Manager{ - registry: newSessionRegistry(), - syncHub: newSyncHub(), - chatStore: newChatRunStore(), - tunnels: newTunnelStore(), + m := &Manager{ + registry: newSessionRegistry(), + syncHub: newSyncHub(), + tunnels: newTunnelRuntime(), + cmdQueue: newCommandQueue(defaultCommandQueueTimeout), + workspaceHub: newWorkspaceActivityHub(), } + m.convStreams = newConversationStreamStore(m.IsOnline) + go m.tunnelExpirySweepLoop() + return m } -func NewManagerWithChatEventStore(store ChatEventStore) (*Manager, error) { - manager := NewManager() - manager.chatStore.eventStore = store - if store != nil { - if err := store.FailOpenRuns("Gateway restarted before the remote chat run finished. Please retry."); err != nil { - return nil, err - } - } - return manager, nil -} diff --git a/crates/agent-gateway/internal/session/manager_chat_queue.go b/crates/agent-gateway/internal/session/manager_chat_queue.go index 415aba595..40c86c846 100644 --- a/crates/agent-gateway/internal/session/manager_chat_queue.go +++ b/crates/agent-gateway/internal/session/manager_chat_queue.go @@ -1,20 +1,39 @@ package session import ( + "sort" + "strings" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -func (m *Manager) SubscribeChatQueueEvents() (<-chan *gatewayv1.ChatQueueEvent, func()) { - ch := make(chan *gatewayv1.ChatQueueEvent, 64) +type chatQueueSnapshotRecord struct { + event *gatewayv1.ChatQueueEvent + sessionEpoch uint64 +} +func (m *Manager) SubscribeChatQueueEvents() (<-chan *gatewayv1.ChatQueueEvent, func()) { m.syncHub.chatQueueMu.Lock() + replay := make([]*gatewayv1.ChatQueueEvent, 0, len(m.syncHub.chatQueueSnapshots)) + conversationIDs := make([]string, 0, len(m.syncHub.chatQueueSnapshots)) + for conversationID := range m.syncHub.chatQueueSnapshots { + conversationIDs = append(conversationIDs, conversationID) + } + sort.Strings(conversationIDs) + for _, conversationID := range conversationIDs { + replay = append(replay, cloneChatQueueEvent(m.syncHub.chatQueueSnapshots[conversationID].event)) + } + ch := make(chan *gatewayv1.ChatQueueEvent, 128+len(replay)) subID := m.syncHub.nextChatQueueSubID m.syncHub.nextChatQueueSubID += 1 m.syncHub.chatQueueSubscribers[subID] = ch m.syncHub.chatQueueMu.Unlock() + for _, event := range replay { + ch <- event + } + cleanup := func() { m.syncHub.chatQueueMu.Lock() if _, ok := m.syncHub.chatQueueSubscribers[subID]; ok { @@ -26,12 +45,48 @@ func (m *Manager) SubscribeChatQueueEvents() (<-chan *gatewayv1.ChatQueueEvent, return ch, cleanup } +func (m *Manager) ChatQueueSnapshot(conversationID string) (*gatewayv1.ChatQueueEvent, bool) { + key := strings.TrimSpace(conversationID) + if key == "" { + return nil, false + } + + m.syncHub.chatQueueMu.Lock() + defer m.syncHub.chatQueueMu.Unlock() + + record, ok := m.syncHub.chatQueueSnapshots[key] + if !ok { + return nil, false + } + return cloneChatQueueEvent(record.event), true +} + func (m *Manager) broadcastChatQueueEvent(event *gatewayv1.ChatQueueEvent) { if event == nil { return } + normalized := cloneChatQueueEvent(event) + conversationID := strings.TrimSpace(normalized.GetConversationId()) + if conversationID != "" { + normalized.ConversationId = conversationID + } + sessionEpoch := m.currentSessionEpoch() m.syncHub.chatQueueMu.Lock() + if conversationID != "" { + if existing := m.syncHub.chatQueueSnapshots[conversationID]; existing.event != nil && existing.sessionEpoch == sessionEpoch { + existingRevision := existing.event.GetRevision() + incomingRevision := normalized.GetRevision() + if existingRevision > 0 && (incomingRevision == 0 || incomingRevision < existingRevision) { + m.syncHub.chatQueueMu.Unlock() + return + } + } + m.syncHub.chatQueueSnapshots[conversationID] = chatQueueSnapshotRecord{ + event: cloneChatQueueEvent(normalized), + sessionEpoch: sessionEpoch, + } + } subscribers := make([]chan *gatewayv1.ChatQueueEvent, 0, len(m.syncHub.chatQueueSubscribers)) for _, ch := range m.syncHub.chatQueueSubscribers { subscribers = append(subscribers, ch) @@ -40,8 +95,19 @@ func (m *Manager) broadcastChatQueueEvent(event *gatewayv1.ChatQueueEvent) { for _, ch := range subscribers { select { - case ch <- event: + case ch <- cloneChatQueueEvent(normalized): case <-time.After(50 * time.Millisecond): } } } + +func cloneChatQueueEvent(event *gatewayv1.ChatQueueEvent) *gatewayv1.ChatQueueEvent { + if event == nil { + return nil + } + return &gatewayv1.ChatQueueEvent{ + ConversationId: event.GetConversationId(), + SnapshotJson: event.GetSnapshotJson(), + Revision: event.GetRevision(), + } +} diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go deleted file mode 100644 index e826e2204..000000000 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ /dev/null @@ -1,1945 +0,0 @@ -package session - -import ( - "encoding/json" - "log" - "sort" - "strconv" - "strings" - "sync" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (m *Manager) StartPendingChatCommandRun( - requestID string, - conversationID string, - clientRequestID string, - workdirInput ...string, -) (ChatRunSnapshot, bool, error) { - return m.startPendingChatCommandRun(requestID, conversationID, clientRequestID, workdirInput...) -} - -func conversationLiveChatRunID(conversationID string) string { - return "conversation-live-" + strings.TrimSpace(conversationID) -} - -func (m *Manager) ensureConversationChatRun( - conversationID string, - workdir string, - now time.Time, -) (ChatRunSnapshot, bool, error) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return ChatRunSnapshot{}, false, ErrChatRunNotFound - } - workdir = strings.TrimSpace(workdir) - if now.IsZero() { - now = time.Now() - } - - requestID := conversationLiveChatRunID(conversationID) - sessionEpoch := m.currentSessionEpoch() - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - if existingRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); existingRequestID != "" { - if run := m.chatStore.chatRuns[existingRequestID]; run != nil && !run.done { - if workdir != "" { - run.workdir = workdir - } - run.applyState(ChatRunStateRunning) - run.updatedAt = now - snapshot := run.snapshot() - m.chatStore.chatMu.Unlock() - return snapshot, false, nil - } - } - if run := m.chatStore.chatRuns[requestID]; run != nil && !run.done { - if workdir != "" { - run.workdir = workdir - } - run.conversationID = conversationID - m.chatStore.chatRunByConversation[conversationID] = requestID - run.applyState(ChatRunStateRunning) - run.updatedAt = now - snapshot := run.snapshot() - m.chatStore.chatMu.Unlock() - return snapshot, false, nil - } - m.chatStore.chatMu.Unlock() - - if store := m.chatStore.eventStore; store != nil { - snapshot, created, err := store.StartRun(ChatRunStoreStart{ - RequestID: requestID, - ConversationID: conversationID, - Workdir: workdir, - State: ChatRunStateRunning, - CreatedAt: now, - }) - if err != nil { - return ChatRunSnapshot{}, false, err - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - if liveRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); liveRequestID != "" && liveRequestID != requestID { - if liveRun := m.chatStore.chatRuns[liveRequestID]; liveRun != nil && !liveRun.done { - if workdir != "" { - liveRun.workdir = workdir - } - liveRun.applyState(ChatRunStateRunning) - liveRun.updatedAt = now - return liveRun.snapshot(), false, nil - } - } - if created { - if latestSeq := m.latestConversationSeqLocked(conversationID); latestSeq > snapshot.LatestSeq { - snapshot.LatestSeq = latestSeq - } - } - reopenedDoneRun := false - if existing := m.chatStore.chatRuns[requestID]; existing != nil && existing.done { - reopenedDoneRun = true - } - run := m.upsertChatRunSnapshotLocked(snapshot, sessionEpoch, now) - if run == nil { - return snapshot, created, nil - } - if reopenedDoneRun { - run.events = nil - } - if workdir != "" { - run.workdir = workdir - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - run.applyState(ChatRunStateRunning) - run.updatedAt = now - return run.snapshot(), created, nil - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - if liveRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); liveRequestID != "" && liveRequestID != requestID { - if liveRun := m.chatStore.chatRuns[liveRequestID]; liveRun != nil && !liveRun.done { - if workdir != "" { - liveRun.workdir = workdir - } - liveRun.applyState(ChatRunStateRunning) - liveRun.updatedAt = now - return liveRun.snapshot(), false, nil - } - } - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - m.removeChatRunLocked(requestID, existing) - } - m.chatStore.nextChatRunEpoch += 1 - run := &chatRun{ - requestID: requestID, - conversationID: conversationID, - workdir: workdir, - sessionEpoch: sessionEpoch, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateRunning, - nextSeq: m.latestConversationSeqLocked(conversationID), - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateRunning) - m.chatStore.chatRuns[requestID] = run - m.chatStore.chatRunByConversation[conversationID] = requestID - return run.snapshot(), true, nil -} - -func (m *Manager) StartAcceptedChatCommandRun( - requestID string, - conversationID string, - clientRequestID string, - workdir string, - initialPayloads []map[string]any, -) (ChatRunSnapshot, bool, int64, error) { - m.chatStore.chatCommandMu.Lock() - defer m.chatStore.chatCommandMu.Unlock() - - snapshot, created, err := m.startPendingChatCommandRun( - requestID, - conversationID, - clientRequestID, - workdir, - ) - if err != nil || !created { - return snapshot, created, snapshot.LatestSeq, err - } - - m.MarkChatRunControl(snapshot.RequestID, conversationID, "accepted", "", "") - acceptedSeq := snapshot.LatestSeq - if acceptedSnapshot, ok := m.ChatRunSnapshot(snapshot.RequestID, conversationID); ok { - snapshot = acceptedSnapshot - acceptedSeq = acceptedSnapshot.LatestSeq - } - if len(initialPayloads) > 0 { - m.MarkChatRunPayloads(snapshot.RequestID, conversationID, initialPayloads) - if nextSnapshot, ok := m.ChatRunSnapshot(snapshot.RequestID, conversationID); ok { - snapshot = nextSnapshot - } - } - return snapshot, true, acceptedSeq, nil -} - -func (m *Manager) startPendingChatCommandRun( - requestID string, - conversationID string, - clientRequestID string, - workdirInput ...string, -) (ChatRunSnapshot, bool, error) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return ChatRunSnapshot{}, false, ErrChatRunNotFound - } - - now := time.Now() - conversationID = strings.TrimSpace(conversationID) - clientRequestID = strings.TrimSpace(clientRequestID) - workdir := "" - if len(workdirInput) > 0 { - workdir = strings.TrimSpace(workdirInput[0]) - } - sessionEpoch := m.currentSessionEpoch() - if store := m.chatStore.eventStore; store != nil { - snapshot, created, err := store.StartRun(ChatRunStoreStart{ - RequestID: requestID, - ConversationID: conversationID, - ClientRequestID: clientRequestID, - Workdir: workdir, - CreatedAt: now, - }) - if err != nil { - return ChatRunSnapshot{}, false, err - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - if created { - if latestSeq := m.latestConversationSeqLocked(conversationID); latestSeq > snapshot.LatestSeq { - snapshot.LatestSeq = latestSeq - } - } - run := m.upsertChatRunSnapshotLocked(snapshot, sessionEpoch, now) - if run == nil { - return snapshot, created, nil - } - return run.snapshot(), created, nil - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - - if clientRequestID != "" { - if existingRequestID := m.chatStore.chatRunByClientRequest[clientRequestID]; existingRequestID != "" { - if existing := m.chatStore.chatRuns[existingRequestID]; existing != nil { - if !existing.done { - if workdir != "" && existing.workdir == "" { - existing.workdir = workdir - } - return existing.snapshot(), false, nil - } - m.releaseCompletedChatRunLocked(existingRequestID, existing) - } - delete(m.chatStore.chatRunByClientRequest, clientRequestID) - } - } - - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - m.removeChatRunLocked(requestID, existing) - } - - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run := &chatRun{ - requestID: requestID, - conversationID: conversationID, - clientRequestID: clientRequestID, - workdir: workdir, - sessionEpoch: sessionEpoch, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - if clientRequestID != "" { - m.chatStore.chatRunByClientRequest[clientRequestID] = requestID - } - - return run.snapshot(), true, nil -} - -func (m *Manager) latestConversationSeqLocked(conversationID string) int64 { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return 0 - } - var latestSeq int64 - for _, run := range m.chatStore.chatRuns { - if run == nil || strings.TrimSpace(run.conversationID) != conversationID { - continue - } - if run.nextSeq > latestSeq { - latestSeq = run.nextSeq - } - } - return latestSeq -} - -func (m *Manager) chatRunCanClaimConversationLocked(conversationID string, requestID string) bool { - conversationID = strings.TrimSpace(conversationID) - requestID = strings.TrimSpace(requestID) - if conversationID == "" || requestID == "" { - return false - } - currentRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]) - if currentRequestID == "" || currentRequestID == requestID { - return true - } - currentRun := m.chatStore.chatRuns[currentRequestID] - return currentRun == nil || currentRun.done -} - -func chatRunControlCanClaimConversation(controlType string, state string) bool { - if normalizeChatRunState(state) == ChatRunStateRunning { - return true - } - return strings.TrimSpace(controlType) == "started" -} - -func (m *Manager) upsertChatRunSnapshotLocked( - snapshot ChatRunSnapshot, - sessionEpoch uint64, - now time.Time, -) *chatRun { - requestID := strings.TrimSpace(snapshot.RequestID) - if requestID == "" { - return nil - } - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - m.applyChatRunSnapshotLocked(existing, snapshot, now) - return existing - } - if snapshot.RunEpoch > m.chatStore.nextChatRunEpoch { - m.chatStore.nextChatRunEpoch = snapshot.RunEpoch - } - run := &chatRun{ - requestID: requestID, - conversationID: strings.TrimSpace(snapshot.ConversationID), - clientRequestID: strings.TrimSpace(snapshot.ClientRequestID), - workdir: strings.TrimSpace(snapshot.Workdir), - sessionEpoch: sessionEpoch, - runEpoch: snapshot.RunEpoch, - state: normalizeChatRunState(snapshot.State), - errorCode: strings.TrimSpace(snapshot.ErrorCode), - nextSeq: snapshot.LatestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - if run.runEpoch <= 0 { - m.chatStore.nextChatRunEpoch += 1 - run.runEpoch = m.chatStore.nextChatRunEpoch - } - run.applyState(run.state) - if snapshot.Done { - run.applyState(ChatRunStateCompleted) - if snapshot.State == ChatRunStateFailed { - run.applyState(ChatRunStateFailed) - run.errorCode = strings.TrimSpace(snapshot.ErrorCode) - } else if snapshot.State == ChatRunStateCancelled { - run.applyState(ChatRunStateCancelled) - } - } - m.chatStore.chatRuns[requestID] = run - if run.conversationID != "" && m.chatRunCanClaimConversationLocked(run.conversationID, requestID) { - m.chatStore.chatRunByConversation[run.conversationID] = requestID - } - if run.clientRequestID != "" { - m.chatStore.chatRunByClientRequest[run.clientRequestID] = requestID - } - return run -} - -func (m *Manager) applyChatRunSnapshotLocked(run *chatRun, snapshot ChatRunSnapshot, now time.Time) { - if run == nil { - return - } - requestID := strings.TrimSpace(snapshot.RequestID) - if requestID == "" { - requestID = run.requestID - } - conversationID := strings.TrimSpace(snapshot.ConversationID) - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - if clientRequestID := strings.TrimSpace(snapshot.ClientRequestID); clientRequestID != "" { - run.clientRequestID = clientRequestID - m.chatStore.chatRunByClientRequest[clientRequestID] = requestID - } - if workdir := strings.TrimSpace(snapshot.Workdir); workdir != "" { - run.workdir = workdir - } - if snapshot.RunEpoch > 0 { - run.runEpoch = snapshot.RunEpoch - if snapshot.RunEpoch > m.chatStore.nextChatRunEpoch { - m.chatStore.nextChatRunEpoch = snapshot.RunEpoch - } - } - if snapshot.LatestSeq > run.nextSeq { - run.nextSeq = snapshot.LatestSeq - } - if state := normalizeChatRunState(snapshot.State); state != "" { - run.applyState(state) - } - if snapshot.Done && !run.done { - run.applyState(ChatRunStateCompleted) - } - if snapshot.ErrorCode != "" { - run.errorCode = strings.TrimSpace(snapshot.ErrorCode) - } - run.updatedAt = now -} - -func (m *Manager) RemoveChatRun(requestID string) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - run := m.chatStore.chatRuns[requestID] - if run == nil { - return - } - m.removeChatRunLocked(requestID, run) -} - -func (m *Manager) RemoveChatRunByConversation(conversationID string) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - requestID := m.chatStore.chatRunByConversation[conversationID] - run := m.chatStore.chatRuns[requestID] - if run == nil { - for candidateRequestID, candidateRun := range m.chatStore.chatRuns { - if strings.TrimSpace(candidateRun.conversationID) == conversationID { - requestID = candidateRequestID - run = candidateRun - break - } - } - } - if run == nil { - return - } - m.removeChatRunLocked(requestID, run) -} - -func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - now := time.Now() - m.pruneExpiredChatRunsLocked(now) - - seen := make(map[string]int, len(m.chatStore.chatRuns)+len(m.chatStore.historyActiveRuns)) - summaries := make([]ActiveChatRunSummary, 0, len(m.chatStore.chatRuns)+len(m.chatStore.historyActiveRuns)) - for _, run := range m.chatStore.chatRuns { - if run == nil || run.done || normalizeChatRunState(run.state) != ChatRunStateRunning { - continue - } - conversationID := strings.TrimSpace(run.conversationID) - if conversationID == "" { - continue - } - firstSeq := run.snapshot().FirstSeq - if firstSeq <= 0 { - firstSeq = run.nextSeq + 1 - } - summary := ActiveChatRunSummary{ - ConversationID: conversationID, - RequestID: strings.TrimSpace(run.requestID), - Workdir: strings.TrimSpace(run.workdir), - FirstSeq: firstSeq, - LatestSeq: run.nextSeq, - RunEpoch: run.runEpoch, - UpdatedAt: run.updatedAt.UnixMilli(), - } - if index, ok := seen[conversationID]; ok { - if summaries[index].Workdir == "" { - summaries[index].Workdir = summary.Workdir - } - currentOwner := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]) - if shouldReplaceActiveChatRunSummary(summary, summaries[index], currentOwner) { - summaries[index].RequestID = summary.RequestID - summaries[index].FirstSeq = summary.FirstSeq - summaries[index].LatestSeq = summary.LatestSeq - summaries[index].RunEpoch = summary.RunEpoch - summaries[index].UpdatedAt = summary.UpdatedAt - } - continue - } - seen[conversationID] = len(summaries) - summaries = append(summaries, summary) - } - - for conversationID, run := range m.chatStore.historyActiveRuns { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - continue - } - if now.Sub(run.updatedAt) > chatRunStaleRetention { - delete(m.chatStore.historyActiveRuns, conversationID) - continue - } - workdir := strings.TrimSpace(run.workdir) - updatedAt := run.updatedAt.UnixMilli() - if index, ok := seen[conversationID]; ok { - if summaries[index].Workdir == "" { - summaries[index].Workdir = workdir - } - if updatedAt > summaries[index].UpdatedAt { - summaries[index].UpdatedAt = updatedAt - } - continue - } - seen[conversationID] = len(summaries) - summaries = append(summaries, ActiveChatRunSummary{ - ConversationID: conversationID, - Workdir: workdir, - UpdatedAt: updatedAt, - }) - } - - sort.Slice(summaries, func(i, j int) bool { - return summaries[i].ConversationID < summaries[j].ConversationID - }) - return summaries -} - -func shouldReplaceActiveChatRunSummary(candidate ActiveChatRunSummary, current ActiveChatRunSummary, currentOwner string) bool { - candidatePriority := activeChatRunSummaryPriority(candidate, currentOwner) - currentPriority := activeChatRunSummaryPriority(current, currentOwner) - if candidatePriority != currentPriority { - return candidatePriority > currentPriority - } - return candidate.UpdatedAt > current.UpdatedAt -} - -func activeChatRunSummaryPriority(summary ActiveChatRunSummary, currentOwner string) int { - requestID := strings.TrimSpace(summary.RequestID) - priority := 0 - if requestID != "" { - priority += 1 - } - if requestID != "" && !strings.HasPrefix(requestID, "conversation-live-") { - priority += 2 - } - if currentOwner != "" && requestID == currentOwner { - priority += 4 - } - return priority -} - -func (m *Manager) ActiveChatRunConversationIDs() []string { - summaries := m.ActiveChatRunSummaries() - ids := make([]string, 0, len(summaries)) - for _, summary := range summaries { - if conversationID := strings.TrimSpace(summary.ConversationID); conversationID != "" { - ids = append(ids, conversationID) - } - } - return ids -} - -func (m *Manager) failOpenChatRunsForSessionEpoch(sessionEpoch uint64, message string) { - message = strings.TrimSpace(message) - if message == "" { - message = agentDisconnectedChatRunMessage - } - - data, err := json.Marshal(map[string]string{"message": message}) - if err != nil { - data = []byte(`{"message":"Desktop agent disconnected. Please retry."}`) - } - now := time.Now() - - type broadcastTarget struct { - events []*ChatBroadcastEvent - subscribers []*chatRunSubscriber - persist ChatRunEventAppend - } - targets := make([]broadcastTarget, 0) - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - for requestID, run := range m.chatStore.chatRuns { - if run == nil || run.done || run.sessionEpoch != sessionEpoch { - continue - } - - run.nextSeq += 1 - run.updatedAt = now - run.applyState(ChatRunStateFailed) - run.errorCode = "agent_disconnected" - run.expiresAt = now.Add(chatRunDoneRetention) - - chatEvent := &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_ERROR, - ConversationId: strings.TrimSpace(run.conversationID), - Data: string(data), - } - broadcast := &ChatBroadcastEvent{ - RequestID: requestID, - Event: chatEvent, - Seq: run.nextSeq, - Workdir: strings.TrimSpace(run.workdir), - } - run.appendEvent(broadcast) - persist := chatRunEventAppendSnapshot(run, broadcast, now) - - subscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - subscribers = append(subscribers, subscriber) - } - targets = append(targets, broadcastTarget{ - events: []*ChatBroadcastEvent{broadcast}, - subscribers: subscribers, - persist: persist, - }) - } - m.chatStore.chatMu.Unlock() - - for _, target := range targets { - m.persistChatBroadcast(target.persist) - for _, subscriber := range target.subscribers { - for _, event := range target.events { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(event): - } - } - } - } -} - -func (m *Manager) FailStartingChatRun(requestID string, message string) bool { - failed, sessionEpoch := m.failChatRunIf( - requestID, - message, - "Desktop backend did not accept the remote chat request. Please retry.", - func(run *chatRun) bool { - if run == nil || run.done { - return false - } - state := normalizeChatRunState(run.state) - return state == ChatRunStateQueued - }, - ) - if failed { - m.ClearSessionForEpoch(sessionEpoch) - } - return failed -} - -func (m *Manager) FailUnstartedChatRun(requestID string, message string) bool { - failed, _ := m.failChatRunIf( - requestID, - message, - "Desktop app accepted the remote chat request but did not start it. Please retry.", - func(run *chatRun) bool { - if run == nil || run.done { - return false - } - state := normalizeChatRunState(run.state) - return state != ChatRunStateQueued && - state != ChatRunStateDesktopQueued && - state != ChatRunStateRunning && - !isTerminalChatRunState(state) - }, - ) - return failed -} - -func (m *Manager) failChatRunIf( - requestID string, - message string, - defaultMessage string, - shouldFail func(*chatRun) bool, -) (bool, uint64) { - requestID = strings.TrimSpace(requestID) - message = strings.TrimSpace(message) - if requestID == "" { - return false, 0 - } - if message == "" { - message = defaultMessage - } - - data, err := json.Marshal(map[string]string{"message": message}) - if err != nil { - fallback, marshalErr := json.Marshal(map[string]string{"message": defaultMessage}) - if marshalErr != nil { - fallback = []byte(`{"message":"Remote chat request failed. Please retry."}`) - } - data = fallback - } - - now := time.Now() - var broadcast *ChatBroadcastEvent - var persist ChatRunEventAppend - var runSubscribers []*chatRunSubscriber - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if shouldFail == nil || !shouldFail(run) { - m.chatStore.chatMu.Unlock() - return false, 0 - } - sessionEpoch := run.sessionEpoch - - run.nextSeq += 1 - run.updatedAt = now - run.applyState(ChatRunStateFailed) - run.errorCode = "desktop_runtime_unavailable" - run.expiresAt = now.Add(chatRunDoneRetention) - chatEvent := &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_ERROR, - ConversationId: strings.TrimSpace(run.conversationID), - Data: string(data), - } - broadcast = &ChatBroadcastEvent{ - RequestID: requestID, - Event: chatEvent, - Seq: run.nextSeq, - Workdir: strings.TrimSpace(run.workdir), - } - run.appendEvent(broadcast) - persist = chatRunEventAppendSnapshot(run, broadcast, now) - runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } - m.chatStore.chatMu.Unlock() - - m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } - return true, sessionEpoch -} - -func (m *Manager) SubscribeChatRun( - requestID string, - conversationID string, - afterSeq int64, -) (<-chan *ChatBroadcastEvent, <-chan struct{}, func(), ChatRunSnapshot, error) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if afterSeq < 0 { - afterSeq = 0 - } - conversationReplayRequested := requestID == "" && conversationID != "" - - var persistedReplay []*ChatBroadcastEvent - var persistedSnapshot ChatRunSnapshot - persistedFound := false - if store := m.chatStore.eventStore; store != nil { - snapshot, replay, ok, err := store.Replay(requestID, conversationID, afterSeq, maxBufferedChatRunEvents) - if err != nil { - done := make(chan struct{}) - close(done) - return nil, done, func() {}, ChatRunSnapshot{}, err - } - if ok { - persistedFound = true - persistedSnapshot = snapshot - persistedReplay = replay - requestID = strings.TrimSpace(snapshot.RequestID) - if conversationID == "" { - conversationID = strings.TrimSpace(snapshot.ConversationID) - } - } - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - now := time.Now() - m.pruneExpiredChatRunsLocked(now) - - if conversationReplayRequested && conversationID != "" { - if liveRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); liveRequestID != "" { - requestID = liveRequestID - } - } else if requestID == "" && conversationID != "" { - requestID = m.chatStore.chatRunByConversation[conversationID] - } - run := m.chatStore.chatRuns[requestID] - if run == nil && persistedFound { - run = m.upsertChatRunSnapshotLocked(persistedSnapshot, m.currentSessionEpoch(), now) - if run != nil { - for _, event := range persistedReplay { - if strings.TrimSpace(event.RequestID) == strings.TrimSpace(run.requestID) { - run.appendEvent(event) - } - } - } - } else if run != nil && persistedFound && strings.TrimSpace(run.requestID) == strings.TrimSpace(persistedSnapshot.RequestID) { - m.applyChatRunSnapshotLocked(run, persistedSnapshot, now) - } - if run == nil { - done := make(chan struct{}) - close(done) - return nil, done, func() {}, ChatRunSnapshot{}, ErrChatRunNotFound - } - - replay := make([]*ChatBroadcastEvent, 0) - if persistedFound { - for _, event := range persistedReplay { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - replay = mergeChatReplayEvents(replay, m.collectBufferedChatReplayLocked(run, conversationID, afterSeq, conversationReplayRequested)) - } else if conversationReplayRequested { - for _, candidate := range m.chatStore.chatRuns { - if candidate == nil || strings.TrimSpace(candidate.conversationID) != conversationID { - continue - } - for _, event := range candidate.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - sort.SliceStable(replay, func(i, j int) bool { - return replay[i].Seq < replay[j].Seq - }) - } else { - for _, event := range run.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - - bufferSize := len(replay) + 128 - if bufferSize < 128 { - bufferSize = 128 - } - ch := make(chan *ChatBroadcastEvent, bufferSize) - done := make(chan struct{}) - for _, event := range replay { - ch <- event - } - - subID := -1 - var subscriber *chatRunSubscriber - doneClosed := false - if !run.done { - subID = m.chatStore.nextChatRunSubID - m.chatStore.nextChatRunSubID += 1 - subscriber = &chatRunSubscriber{ - ch: ch, - done: done, - } - run.subscribers[subID] = subscriber - } else if len(replay) == 0 { - close(done) - doneClosed = true - } - - var cleanupOnce sync.Once - cleanup := func() { - cleanupOnce.Do(func() { - m.chatStore.chatMu.Lock() - if subID >= 0 { - if current := m.chatStore.chatRuns[requestID]; current != nil { - delete(current.subscribers, subID) - } - } - m.chatStore.chatMu.Unlock() - if subscriber != nil { - subscriber.close() - } else if !doneClosed { - close(done) - } - }) - } - - return ch, done, cleanup, run.snapshot(), nil -} - -func (m *Manager) collectBufferedChatReplayLocked( - run *chatRun, - conversationID string, - afterSeq int64, - conversationReplayRequested bool, -) []*ChatBroadcastEvent { - if conversationReplayRequested { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return nil - } - replay := make([]*ChatBroadcastEvent, 0) - for _, candidate := range m.chatStore.chatRuns { - if candidate == nil || strings.TrimSpace(candidate.conversationID) != conversationID { - continue - } - for _, event := range candidate.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - return replay - } - if run == nil { - return nil - } - replay := make([]*ChatBroadcastEvent, 0, len(run.events)) - for _, event := range run.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - return replay -} - -func mergeChatReplayEvents( - persisted []*ChatBroadcastEvent, - buffered []*ChatBroadcastEvent, -) []*ChatBroadcastEvent { - if len(persisted) == 0 && len(buffered) == 0 { - return nil - } - merged := make([]*ChatBroadcastEvent, 0, len(persisted)+len(buffered)) - seen := make(map[string]struct{}, len(persisted)+len(buffered)) - appendEvent := func(event *ChatBroadcastEvent) { - if event == nil || event.Seq <= 0 { - return - } - key := strings.TrimSpace(event.RequestID) + "\x00" + strconv.FormatInt(event.Seq, 10) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - merged = append(merged, cloneChatBroadcastEvent(event)) - } - for _, event := range persisted { - appendEvent(event) - } - for _, event := range buffered { - appendEvent(event) - } - sort.SliceStable(merged, func(i, j int) bool { - if merged[i].Seq == merged[j].Seq { - return strings.TrimSpace(merged[i].RequestID) < strings.TrimSpace(merged[j].RequestID) - } - return merged[i].Seq < merged[j].Seq - }) - return merged -} - -func (m *Manager) ChatRunSnapshot( - requestID string, - conversationID string, -) (ChatRunSnapshot, bool) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(time.Now()) - - if requestID == "" && conversationID != "" { - requestID = m.chatStore.chatRunByConversation[conversationID] - } - run := m.chatStore.chatRuns[requestID] - if run == nil { - return ChatRunSnapshot{}, false - } - return run.snapshot(), true -} - -func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot, bool) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return ChatRunSnapshot{}, false - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(time.Now()) - - if requestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); requestID != "" { - if run := m.chatStore.chatRuns[requestID]; chatRunIsRunningForConversation(run, conversationID) { - return run.snapshot(), true - } - } - - var best *chatRun - var bestRequestID string - for requestID, run := range m.chatStore.chatRuns { - if !chatRunIsRunningForConversation(run, conversationID) { - continue - } - if best == nil || - run.updatedAt.After(best.updatedAt) || - (run.updatedAt.Equal(best.updatedAt) && strings.TrimSpace(requestID) > bestRequestID) { - best = run - bestRequestID = strings.TrimSpace(requestID) - } - } - if best == nil { - return ChatRunSnapshot{}, false - } - return best.snapshot(), true -} - -func chatRunIsRunningForConversation(run *chatRun, conversationID string) bool { - return run != nil && - !run.done && - strings.TrimSpace(run.conversationID) == conversationID && - normalizeChatRunState(run.state) == ChatRunStateRunning -} - -func (m *Manager) MarkChatRunControl( - requestID string, - conversationID string, - controlType string, - errorCode string, - message string, -) { - m.markChatRunControl( - strings.TrimSpace(requestID), - strings.TrimSpace(conversationID), - strings.TrimSpace(controlType), - "", - strings.TrimSpace(errorCode), - strings.TrimSpace(message), - time.Now(), - ) -} - -func (m *Manager) MarkChatRunPayload( - requestID string, - conversationID string, - payload map[string]any, -) int64 { - seqs := m.MarkChatRunPayloads(requestID, conversationID, []map[string]any{payload}) - if len(seqs) == 0 { - return 0 - } - return seqs[0] -} - -func (m *Manager) MarkChatRunPayloads( - requestID string, - conversationID string, - payloads []map[string]any, -) []int64 { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if requestID == "" || len(payloads) == 0 { - return nil - } - - now := time.Now() - persists := make([]ChatRunEventAppend, 0, len(payloads)) - broadcasts := make([]*ChatBroadcastEvent, 0, len(payloads)) - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil { - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: m.currentSessionEpoch(), - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - } - if run.done { - m.chatStore.chatMu.Unlock() - return nil - } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - for _, payload := range payloads { - broadcast := m.appendChatPayloadLocked(run, payload, now) - if broadcast == nil { - continue - } - broadcasts = append(broadcasts, broadcast) - if !isEphemeralChatBroadcastEvent(broadcast) { - persists = append(persists, chatRunEventAppendSnapshot(run, broadcast, now)) - } - } - runSubscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } - m.chatStore.chatMu.Unlock() - - if len(broadcasts) == 0 { - return nil - } - m.persistChatBroadcasts(persists) - for _, subscriber := range runSubscribers { - for _, broadcast := range broadcasts { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } - } - seqs := make([]int64, 0, len(broadcasts)) - for _, broadcast := range broadcasts { - seqs = append(seqs, broadcast.Seq) - } - return seqs -} - -func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEvent) { - if event == nil { - return - } - - requestID = strings.TrimSpace(requestID) - conversationID := strings.TrimSpace(event.GetConversationId()) - now := time.Now() - sessionEpoch := m.currentSessionEpoch() - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - broadcast := &ChatBroadcastEvent{ - RequestID: requestID, - Event: event, - } - var persist ChatRunEventAppend - var runSubscribers []*chatRunSubscriber - var firstDelta *ChatBroadcastEvent - run := m.chatStore.chatRuns[requestID] - if run == nil && requestID != "" { - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: sessionEpoch, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - if conversationID != "" { - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - } - if run != nil { - if run.done { - m.chatStore.chatMu.Unlock() - return - } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - if run.workdir == "" { - if activeRun, ok := m.chatStore.historyActiveRuns[conversationID]; ok { - run.workdir = strings.TrimSpace(activeRun.workdir) - } - } - } - if !run.done && normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { - run.applyState(ChatRunStateRunning) - } - run.nextSeq += 1 - run.updatedAt = now - broadcast.Seq = run.nextSeq - broadcast.Workdir = strings.TrimSpace(run.workdir) - if isTerminalChatEvent(event) { - if event.GetType() == gatewayv1.ChatEvent_DONE { - run.applyState(ChatRunStateCompleted) - } else { - run.applyState(ChatRunStateFailed) - if run.errorCode == "" { - run.errorCode = "desktop_error" - } - } - run.expiresAt = now.Add(chatRunDoneRetention) - } - run.appendEvent(broadcast) - if !isEphemeralChatBroadcastEvent(broadcast) { - persist = chatRunEventAppendSnapshot(run, broadcast, now) - } - if isFirstDeltaChatEvent(event) && !run.firstDeltaLogged { - run.firstDeltaLogged = true - firstDelta = cloneChatBroadcastEvent(broadcast) - } - runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } - } - m.chatStore.chatMu.Unlock() - - if firstDelta != nil { - logChatRunSpan("first_delta", firstDelta) - } - m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } -} - -func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.ChatControlEvent) { - if control == nil { - return - } - requestID = strings.TrimSpace(requestID) - if requestID == "" { - requestID = strings.TrimSpace(control.GetRequestId()) - } - conversationID := strings.TrimSpace(control.GetConversationId()) - controlType := strings.TrimSpace(control.GetType()) - state := normalizeChatRunState(control.GetState()) - if state == "" { - state = chatRunStateForControlType(controlType) - } - errorCode := strings.TrimSpace(control.GetErrorCode()) - message := strings.TrimSpace(control.GetMessage()) - m.markChatRunControl(requestID, conversationID, controlType, state, errorCode, message, time.Now()) -} - -func (m *Manager) markChatRunStateSilent( - requestID string, - conversationID string, - state string, - now time.Time, -) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - state = normalizeChatRunState(state) - if requestID == "" || state == "" { - return - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil || run.done { - return - } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if state == ChatRunStateRunning || m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - run.applyState(state) - run.updatedAt = now - if isTerminalChatRunState(state) { - run.expiresAt = now.Add(chatRunDoneRetention) - } -} - -func (m *Manager) markChatRunControl( - requestID string, - conversationID string, - controlType string, - state string, - errorCode string, - message string, - now time.Time, -) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if requestID == "" { - return - } - - state = normalizeChatRunState(state) - controlType = strings.TrimSpace(controlType) - if controlType == "" { - controlType = chatControlTypeForState(state) - } - - var persist ChatRunEventAppend - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil { - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: m.currentSessionEpoch(), - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - } - if run.done { - m.chatStore.chatMu.Unlock() - return - } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if chatRunControlCanClaimConversation(controlType, state) || - m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - broadcast := m.appendChatControlLocked(run, controlType, errorCode, message, now) - persist = chatRunEventAppendSnapshot(run, broadcast, now) - runSubscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } - m.chatStore.chatMu.Unlock() - - if broadcast == nil { - return - } - if span := chatControlSpanName(broadcast.Control); span != "" { - logChatRunSpan(span, broadcast) - } - m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } -} - -func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { - m.dispatchFromAgent(nil, env) -} - -func (m *Manager) DispatchFromAgentForSession(session *AgentSession, env *gatewayv1.AgentEnvelope) { - m.dispatchFromAgent(session, env) -} - -func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.AgentEnvelope) { - m.registry.mu.RLock() - session := m.registry.session - m.registry.mu.RUnlock() - if session == nil || (expected != nil && session != expected) { - return - } - - if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { - m.UpdateRuntimeStatus(session, runtimeStatus) - return - } - - if chatEvent := env.GetChatEvent(); chatEvent != nil { - m.broadcastChatEvent(env.GetRequestId(), chatEvent) - } - - if chatControl := env.GetChatControl(); chatControl != nil { - m.broadcastChatControl(env.GetRequestId(), chatControl) - } - - if historySync := env.GetHistorySync(); historySync != nil { - m.broadcastHistorySync(historySync) - return - } - - if settingsSync := env.GetSettingsSync(); settingsSync != nil { - m.broadcastSettingsSync(settingsSync) - return - } - - if terminalEvent := env.GetTerminalEvent(); terminalEvent != nil { - m.broadcastTerminalEvent(terminalEvent) - return - } - - if sftpEvent := env.GetSftpEvent(); sftpEvent != nil { - m.broadcastSftpEvent(sftpEvent) - return - } - - if chatQueueEvent := env.GetChatQueueEvent(); chatQueueEvent != nil { - m.broadcastChatQueueEvent(chatQueueEvent) - return - } - - if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { - m.dispatchTunnelFrame(tunnelFrame) - return - } - - if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { - m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) - return - } - - session.dispatch(env) -} - -func (r *chatRun) snapshot() ChatRunSnapshot { - firstSeq := int64(0) - if len(r.events) > 0 { - firstSeq = r.events[0].Seq - } - state := normalizeChatRunState(r.state) - return ChatRunSnapshot{ - RequestID: r.requestID, - ConversationID: r.conversationID, - ClientRequestID: r.clientRequestID, - Workdir: r.workdir, - FirstSeq: firstSeq, - LatestSeq: r.nextSeq, - RunEpoch: r.runEpoch, - State: state, - ErrorCode: strings.TrimSpace(r.errorCode), - Done: r.done, - } -} - -func (r *chatRun) applyState(state string) { - state = normalizeChatRunState(state) - if state == "" { - state = ChatRunStateQueued - } - r.state = state - r.accepted = state != ChatRunStateQueued - r.started = state == ChatRunStateRunning || state == ChatRunStateCompleted - r.done = isTerminalChatRunState(state) - if state != ChatRunStateFailed { - r.errorCode = "" - } -} - -func (r *chatRun) appendEvent(event *ChatBroadcastEvent) { - if r == nil || event == nil { - return - } - r.events = appendCappedChatRunEvent(r.events, event, maxBufferedChatRunEvents) -} - -func appendCappedChatRunEvent( - events []*ChatBroadcastEvent, - event *ChatBroadcastEvent, - limit int, -) []*ChatBroadcastEvent { - if event == nil { - return events - } - if limit <= 0 { - return events[:0] - } - cloned := cloneChatBroadcastEvent(event) - if len(events) < limit { - return append(events, cloned) - } - if len(events) > limit { - events = events[len(events)-limit:] - } - copy(events, events[1:]) - events[len(events)-1] = cloned - return events -} - -func (r *chatRun) shouldPrune(now time.Time) bool { - if r == nil { - return true - } - if r.done { - return !r.expiresAt.IsZero() && now.After(r.expiresAt) - } - if chatRunUpdatedBefore(r.updatedAt, now, chatRunStaleRetention) { - return true - } - return normalizeChatRunState(r.state) != ChatRunStateRunning && - normalizeChatRunState(r.state) != ChatRunStateDesktopQueued && - chatRunUpdatedBefore(r.updatedAt, now, chatRunStartRetention) -} - -func chatRunUpdatedBefore(updatedAt time.Time, now time.Time, retention time.Duration) bool { - return !updatedAt.IsZero() && retention > 0 && now.Sub(updatedAt) > retention -} - -func (s *chatRunSubscriber) close() { - s.closeOnce.Do(func() { - close(s.done) - }) -} - -func (m *Manager) pruneExpiredChatRunsLocked(now time.Time) { - for requestID, run := range m.chatStore.chatRuns { - if run == nil { - delete(m.chatStore.chatRuns, requestID) - continue - } - if run.shouldPrune(now) { - m.removeChatRunLocked(requestID, run) - } - } -} - -func (m *Manager) removeChatRunLocked(requestID string, run *chatRun) { - if run.conversationID != "" && m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - if run.clientRequestID != "" && m.chatStore.chatRunByClientRequest[run.clientRequestID] == requestID { - delete(m.chatStore.chatRunByClientRequest, run.clientRequestID) - } - delete(m.chatStore.chatRuns, requestID) - for _, subscriber := range run.subscribers { - subscriber.close() - } -} - -func (m *Manager) releaseCompletedChatRunLocked(requestID string, run *chatRun) { - if run.conversationID != "" && m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - if run.clientRequestID != "" && m.chatStore.chatRunByClientRequest[run.clientRequestID] == requestID { - delete(m.chatStore.chatRunByClientRequest, run.clientRequestID) - } - delete(m.chatStore.chatRuns, requestID) -} - -func cloneChatBroadcastEvent(event *ChatBroadcastEvent) *ChatBroadcastEvent { - if event == nil { - return nil - } - return &ChatBroadcastEvent{ - RequestID: event.RequestID, - Event: event.Event, - Control: event.Control, - Payload: cloneChatPayloadMap(event.Payload), - Seq: event.Seq, - Workdir: event.Workdir, - } -} - -func cloneChatPayloadMap(input map[string]any) map[string]any { - if len(input) == 0 { - return nil - } - raw, err := json.Marshal(input) - if err != nil { - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out - } - var out map[string]any - if err := json.Unmarshal(raw, &out); err != nil { - out = make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - } - return out -} - -func normalizeChatRunState(state string) string { - switch strings.TrimSpace(state) { - case ChatRunStateQueued: - return ChatRunStateQueued - case ChatRunStateDelivered: - return ChatRunStateDelivered - case ChatRunStateClaimed: - return ChatRunStateClaimed - case ChatRunStateStarting: - return ChatRunStateStarting - case ChatRunStateDesktopQueued: - return ChatRunStateDesktopQueued - case ChatRunStateRunning: - return ChatRunStateRunning - case ChatRunStateCompleted: - return ChatRunStateCompleted - case ChatRunStateFailed: - return ChatRunStateFailed - case ChatRunStateCancelled: - return ChatRunStateCancelled - default: - return "" - } -} - -func isTerminalChatRunState(state string) bool { - switch normalizeChatRunState(state) { - case ChatRunStateCompleted, ChatRunStateFailed, ChatRunStateCancelled: - return true - default: - return false - } -} - -func ChatRunStateIsActive(state string) bool { - switch normalizeChatRunState(state) { - case ChatRunStateQueued, ChatRunStateDelivered, ChatRunStateClaimed, ChatRunStateStarting, ChatRunStateDesktopQueued, ChatRunStateRunning: - return true - default: - return false - } -} - -func chatRunStateForControlType(controlType string) string { - switch strings.TrimSpace(controlType) { - case "accepted": - return ChatRunStateQueued - case "delivered": - return ChatRunStateDelivered - case "claimed": - return ChatRunStateClaimed - case "starting": - return ChatRunStateStarting - case "queued_in_gui": - return ChatRunStateDesktopQueued - case "started": - return ChatRunStateRunning - case "completed": - return ChatRunStateCompleted - case "failed": - return ChatRunStateFailed - case "cancelled": - return ChatRunStateCancelled - default: - return "" - } -} - -func chatControlTypeForState(state string) string { - switch normalizeChatRunState(state) { - case ChatRunStateQueued: - return "accepted" - case ChatRunStateDelivered: - return "delivered" - case ChatRunStateClaimed: - return "claimed" - case ChatRunStateStarting: - return "starting" - case ChatRunStateDesktopQueued: - return "queued_in_gui" - case ChatRunStateRunning: - return "started" - case ChatRunStateCompleted: - return "completed" - case ChatRunStateFailed: - return "failed" - case ChatRunStateCancelled: - return "cancelled" - default: - return "progress" - } -} - -func (m *Manager) appendChatControlLocked( - run *chatRun, - controlType string, - errorCode string, - message string, - now time.Time, -) *ChatBroadcastEvent { - if run == nil { - return nil - } - controlType = strings.TrimSpace(controlType) - state := chatRunStateForControlType(controlType) - if state == "" { - state = normalizeChatRunState(run.state) - } - if state == "" { - state = ChatRunStateQueued - } - run.applyState(state) - if errorCode = strings.TrimSpace(errorCode); errorCode != "" { - run.errorCode = errorCode - } - run.updatedAt = now - if isTerminalChatRunState(state) { - run.expiresAt = now.Add(chatRunDoneRetention) - } - run.nextSeq += 1 - seq := run.nextSeq - if controlType == "" { - controlType = chatControlTypeForState(state) - } - control := &gatewayv1.ChatControlEvent{ - RequestId: strings.TrimSpace(run.requestID), - ClientRequestId: strings.TrimSpace(run.clientRequestID), - ConversationId: strings.TrimSpace(run.conversationID), - RunEpoch: run.runEpoch, - Type: controlType, - State: normalizeChatRunState(run.state), - ErrorCode: strings.TrimSpace(run.errorCode), - Message: strings.TrimSpace(message), - Seq: seq, - } - broadcast := &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(run.requestID), - Control: control, - Seq: seq, - Workdir: strings.TrimSpace(run.workdir), - } - run.appendEvent(broadcast) - return broadcast -} - -func (m *Manager) appendChatPayloadLocked( - run *chatRun, - payload map[string]any, - now time.Time, -) *ChatBroadcastEvent { - if run == nil || len(payload) == 0 { - return nil - } - run.updatedAt = now - run.nextSeq += 1 - seq := run.nextSeq - nextPayload := cloneChatPayloadMap(payload) - if nextPayload == nil { - nextPayload = make(map[string]any) - } - if eventType, _ := nextPayload["type"].(string); strings.TrimSpace(eventType) != "" { - nextPayload["type"] = strings.TrimSpace(eventType) - } - nextPayload["request_id"] = strings.TrimSpace(run.requestID) - nextPayload["client_request_id"] = strings.TrimSpace(run.clientRequestID) - nextPayload["conversation_id"] = strings.TrimSpace(run.conversationID) - nextPayload["run_epoch"] = run.runEpoch - nextPayload["state"] = normalizeChatRunState(run.state) - nextPayload["seq"] = seq - broadcast := &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(run.requestID), - Payload: nextPayload, - Seq: seq, - Workdir: strings.TrimSpace(run.workdir), - } - run.appendEvent(broadcast) - return broadcast -} - -func chatRunEventAppendSnapshot( - run *chatRun, - broadcast *ChatBroadcastEvent, - now time.Time, -) ChatRunEventAppend { - if run == nil || broadcast == nil { - return ChatRunEventAppend{} - } - return ChatRunEventAppend{ - RequestID: strings.TrimSpace(run.requestID), - ConversationID: strings.TrimSpace(run.conversationID), - ClientRequestID: strings.TrimSpace(run.clientRequestID), - Workdir: strings.TrimSpace(run.workdir), - RunEpoch: run.runEpoch, - State: normalizeChatRunState(run.state), - ErrorCode: strings.TrimSpace(run.errorCode), - Done: run.done, - Event: cloneChatBroadcastEvent(broadcast), - CreatedAt: now, - } -} - -func (m *Manager) persistChatBroadcast(input ChatRunEventAppend) { - m.persistChatBroadcasts([]ChatRunEventAppend{input}) -} - -func (m *Manager) persistChatBroadcasts(inputs []ChatRunEventAppend) { - if m.chatStore.eventStore == nil { - return - } - validInputs := make([]ChatRunEventAppend, 0, len(inputs)) - for _, input := range inputs { - if input.Event != nil { - validInputs = append(validInputs, input) - } - } - if len(validInputs) == 0 || m.chatStore.eventStore == nil { - return - } - if err := m.chatStore.eventStore.AppendEvents(validInputs); err != nil { - first := validInputs[0] - log.Printf("persist chat events failed: run_id=%s count=%d first_seq=%d err=%v", first.RequestID, len(validInputs), first.Event.Seq, err) - } -} - -func isTerminalChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil { - return false - } - return event.GetType() == gatewayv1.ChatEvent_DONE || event.GetType() == gatewayv1.ChatEvent_ERROR -} - -func isFirstDeltaChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil { - return false - } - switch event.GetType() { - case gatewayv1.ChatEvent_TOKEN, - gatewayv1.ChatEvent_THINKING, - gatewayv1.ChatEvent_TOOL_CALL, - gatewayv1.ChatEvent_TOOL_STATUS, - gatewayv1.ChatEvent_HOSTED_SEARCH: - return true - default: - return false - } -} - -func isEphemeralChatPayload(payload map[string]any) bool { - if payload == nil { - return false - } - eventType, _ := payload["type"].(string) - return strings.TrimSpace(eventType) == "tool_call_delta" -} - -func isEphemeralChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil || event.GetType() != gatewayv1.ChatEvent_TOOL_CALL { - return false - } - var payload map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(event.GetData())), &payload); err != nil { - return false - } - return isEphemeralChatPayload(payload) -} - -func isEphemeralChatBroadcastEvent(event *ChatBroadcastEvent) bool { - if event == nil { - return false - } - if len(event.Payload) > 0 { - return isEphemeralChatPayload(event.Payload) - } - return isEphemeralChatEvent(event.Event) -} - -func chatControlSpanName(control *gatewayv1.ChatControlEvent) string { - if control == nil { - return "" - } - switch strings.TrimSpace(control.GetType()) { - case "claimed": - return "runtime_claimed" - case "started": - return "runtime_started" - case "completed": - return "run_completed" - case "failed": - return "run_failed" - case "cancelled": - return "run_cancelled" - default: - return "" - } -} - -func logChatRunSpan(span string, event *ChatBroadcastEvent) { - if event == nil { - return - } - runID := strings.TrimSpace(event.RequestID) - conversationID := "" - clientRequestID := "" - if event.Control != nil { - conversationID = strings.TrimSpace(event.Control.GetConversationId()) - clientRequestID = strings.TrimSpace(event.Control.GetClientRequestId()) - } else if event.Payload != nil { - if value, ok := event.Payload["conversation_id"].(string); ok { - conversationID = strings.TrimSpace(value) - } - if value, ok := event.Payload["client_request_id"].(string); ok { - clientRequestID = strings.TrimSpace(value) - } - } else if event.Event != nil { - conversationID = strings.TrimSpace(event.Event.GetConversationId()) - } - log.Printf( - "chat_run_span span=%s run_id=%q conversation_id=%q client_request_id=%q seq=%d", - strings.TrimSpace(span), - runID, - conversationID, - clientRequestID, - event.Seq, - ) -} diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go new file mode 100644 index 000000000..5c80bd0af --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -0,0 +1,106 @@ +package session + +import ( + "strings" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { + m.dispatchFromAgent(nil, env) +} + +func (m *Manager) DispatchFromAgentForSession(session *AgentSession, env *gatewayv1.AgentEnvelope) { + m.dispatchFromAgent(session, env) +} + +func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.AgentEnvelope) { + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session == nil || (expected != nil && session != expected) { + return + } + + if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { + m.UpdateRuntimeStatus(session, runtimeStatus) + m.convStreams.onRuntimeStatus(runtimeStatus, time.Now()) + return + } + + if env.GetChatEvent() != nil || env.GetChatControl() != nil || env.GetChatRuntimeSnapshot() != nil { + m.touchRuntimeActivity(session) + } + + if runtimeSnapshot := env.GetChatRuntimeSnapshot(); runtimeSnapshot != nil { + m.ingestRuntimeSnapshot(runtimeSnapshot) + return + } + + if chatEvent := env.GetChatEvent(); chatEvent != nil { + m.ingestChatEvent(env.GetRequestId(), chatEvent) + } + + if chatControl := env.GetChatControl(); chatControl != nil { + m.ingestChatControl(env.GetRequestId(), chatControl) + } + + if historySync := env.GetHistorySync(); historySync != nil { + // Agent-sent running/idle activity is dropped: conversation activity + // is derived from run lifecycle transitions in the stream store, which + // always carry run ids. + switch strings.TrimSpace(historySync.GetKind()) { + case "running", "idle": + return + } + m.broadcastHistorySync(historySync) + return + } + + if settingsSync := env.GetSettingsSync(); settingsSync != nil { + m.broadcastSettingsSync(settingsSync) + return + } + + if terminalEvent := env.GetTerminalEvent(); terminalEvent != nil { + m.broadcastTerminalEvent(terminalEvent) + return + } + + if sftpEvent := env.GetSftpEvent(); sftpEvent != nil { + m.broadcastSftpEvent(sftpEvent) + return + } + + if chatQueueEvent := env.GetChatQueueEvent(); chatQueueEvent != nil { + m.broadcastChatQueueEvent(chatQueueEvent) + return + } + + if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { + m.dispatchTunnelFrame(tunnelFrame) + return + } + + if workspaceActivity := env.GetWorkspaceActivity(); workspaceActivity != nil { + m.broadcastWorkspaceActivity(workspaceActivity) + return + } + + // Desired-state and probe payloads fan out broadcasts and relay probes; + // run them off the gRPC read loop so tunnel frames keep flowing. + if tunnelDesired := env.GetTunnelDesired(); tunnelDesired != nil { + go m.ApplyDesiredState(tunnelDesired) + return + } + + if tunnelProbeReport := env.GetTunnelProbeReport(); tunnelProbeReport != nil { + go m.ApplyProbeReport(tunnelProbeReport) + return + } + + // TunnelMutationResult intentionally falls through to session.dispatch: + // it answers a gateway-issued request and correlates by request id. + session.dispatch(env) +} diff --git a/crates/agent-gateway/internal/session/manager_history_sync.go b/crates/agent-gateway/internal/session/manager_history_sync.go index ecad46d6b..9b67bfa38 100644 --- a/crates/agent-gateway/internal/session/manager_history_sync.go +++ b/crates/agent-gateway/internal/session/manager_history_sync.go @@ -1,15 +1,11 @@ package session import ( - "log" - "strings" - "time" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) func (m *Manager) SubscribeHistorySync() (<-chan *gatewayv1.HistorySyncEvent, func()) { - ch := make(chan *gatewayv1.HistorySyncEvent, 32) + ch := make(chan *gatewayv1.HistorySyncEvent, 128) m.syncHub.historyMu.Lock() subID := m.syncHub.nextHistorySubID @@ -35,9 +31,6 @@ func (m *Manager) broadcastHistorySync(event *gatewayv1.HistorySyncEvent) { return } - m.updateActiveHistoryRun(event) - m.releaseCompletedChatRunAfterHistoryUpsert(event) - m.syncHub.historyMu.Lock() subscribers := make([]chan *gatewayv1.HistorySyncEvent, 0, len(m.syncHub.historySubscribers)) for _, ch := range m.syncHub.historySubscribers { @@ -52,93 +45,3 @@ func (m *Manager) broadcastHistorySync(event *gatewayv1.HistorySyncEvent) { } } } - -func historySyncConversationID(event *gatewayv1.HistorySyncEvent) string { - conversationID := strings.TrimSpace(event.GetConversationId()) - if conversationID == "" && event.GetConversation() != nil { - conversationID = strings.TrimSpace(event.GetConversation().GetId()) - } - return conversationID -} - -func historySyncWorkdir(event *gatewayv1.HistorySyncEvent) string { - if event == nil || event.GetConversation() == nil { - return "" - } - return strings.TrimSpace(event.GetConversation().GetCwd()) -} - -func (m *Manager) updateActiveHistoryRun(event *gatewayv1.HistorySyncEvent) { - kind := strings.TrimSpace(event.GetKind()) - conversationID := historySyncConversationID(event) - if conversationID == "" { - return - } - - workdir := historySyncWorkdir(event) - now := time.Now() - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - - switch kind { - case "running": - existing := m.chatStore.historyActiveRuns[conversationID] - if workdir == "" { - workdir = existing.workdir - } - m.chatStore.historyActiveRuns[conversationID] = activeHistoryRun{ - conversationID: conversationID, - workdir: workdir, - updatedAt: now, - } - if requestID := m.chatStore.chatRunByConversation[conversationID]; requestID != "" { - if run := m.chatStore.chatRuns[requestID]; run != nil && workdir != "" { - run.workdir = workdir - } - } - m.chatStore.chatMu.Unlock() - if _, _, err := m.ensureConversationChatRun(conversationID, workdir, now); err != nil { - log.Printf("ensure conversation chat run failed conversation_id=%q: %v", conversationID, err) - } - return - case "idle", "delete": - delete(m.chatStore.historyActiveRuns, conversationID) - case "upsert": - if workdir == "" { - m.chatStore.chatMu.Unlock() - return - } - if existing, ok := m.chatStore.historyActiveRuns[conversationID]; ok { - existing.workdir = workdir - existing.updatedAt = now - m.chatStore.historyActiveRuns[conversationID] = existing - } - if requestID := m.chatStore.chatRunByConversation[conversationID]; requestID != "" { - if run := m.chatStore.chatRuns[requestID]; run != nil { - run.workdir = workdir - } - } - } - m.chatStore.chatMu.Unlock() -} - -func (m *Manager) releaseCompletedChatRunAfterHistoryUpsert(event *gatewayv1.HistorySyncEvent) { - if strings.TrimSpace(event.GetKind()) != "upsert" { - return - } - - conversationID := historySyncConversationID(event) - if conversationID == "" { - return - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - requestID := m.chatStore.chatRunByConversation[conversationID] - run := m.chatStore.chatRuns[requestID] - if run == nil || !run.done { - return - } - m.releaseCompletedChatRunLocked(requestID, run) -} diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index c31f46961..dc699fdeb 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -2,6 +2,7 @@ package session import ( "context" + "errors" "strings" "time" @@ -34,7 +35,6 @@ func (m *Manager) IsOnline() bool { func (m *Manager) SetSession(s *AgentSession) { m.registry.mu.Lock() previous := m.registry.session - previousEpoch := m.registry.sessionEpoch if m.registry.authValid { s.AgentID = m.registry.lastAuth.AgentID s.AgentVersion = m.registry.lastAuth.AgentVersion @@ -53,7 +53,15 @@ func (m *Manager) SetSession(s *AgentSession) { } if previous != nil && previous != s { previous.Close() - m.failOpenChatRunsForSessionEpoch(previousEpoch, agentDisconnectedChatRunMessage) + } + if s != nil && sessionChanged { + m.cmdQueue.DrainTo(s) + // Replay the watched-workdir set: a freshly connected agent starts + // with an empty watch set and only learns a non-empty one from this + // push. An empty set needs no replay. + if m.hasWorkspaceWatchInterest() { + go m.pushWorkspaceWatchSet() + } } } @@ -63,7 +71,6 @@ func (m *Manager) ClearSession(session *AgentSession) { m.registry.mu.Unlock() return } - clearedEpoch := m.registry.sessionEpoch m.registry.session = nil clearRuntimeStatusLocked(m.registry) m.registry.mu.Unlock() @@ -74,7 +81,7 @@ func (m *Manager) ClearSession(session *AgentSession) { session.Close() m.clearTerminalSessionSnapshot() - m.failOpenChatRunsForSessionEpoch(clearedEpoch, agentDisconnectedChatRunMessage) + go m.onAgentSessionCleared() } func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout time.Duration) bool { @@ -92,18 +99,17 @@ func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout ti m.registry.mu.Unlock() return false } - clearedEpoch := m.registry.sessionEpoch m.registry.session = nil clearRuntimeStatusLocked(m.registry) m.registry.mu.Unlock() session.Close() m.clearTerminalSessionSnapshot() - m.failOpenChatRunsForSessionEpoch(clearedEpoch, agentDisconnectedChatRunMessage) + go m.onAgentSessionCleared() return true } -func (m *Manager) ClearSessionForEpoch(sessionEpoch uint64) bool { +func (m *Manager) clearSessionForEpoch(sessionEpoch uint64) bool { m.registry.mu.Lock() session := m.registry.session if session == nil || m.registry.sessionEpoch != sessionEpoch { @@ -116,7 +122,7 @@ func (m *Manager) ClearSessionForEpoch(sessionEpoch uint64) bool { session.Close() m.clearTerminalSessionSnapshot() - m.failOpenChatRunsForSessionEpoch(sessionEpoch, agentDisconnectedChatRunMessage) + go m.onAgentSessionCleared() return true } @@ -181,6 +187,20 @@ func (m *Manager) UpdateRuntimeStatus( m.registry.runtimeActiveRunCount = event.GetActiveRunCount() } +// touchRuntimeActivity refreshes the chat-runtime heartbeat when live chat +// traffic proves the desktop runtime is running, even while the webview's +// own status timer is throttled (hidden/occluded window). Only refreshes an +// already-reporting runtime: a zero heartbeat must not become readiness +// (normalizeRuntimeState("") defaults to "ready"). +func (m *Manager) touchRuntimeActivity(session *AgentSession) { + m.registry.mu.Lock() + defer m.registry.mu.Unlock() + if m.registry.session != session || m.registry.runtimeLastHeartbeat.IsZero() { + return + } + m.registry.runtimeLastHeartbeat = time.Now() +} + func (m *Manager) TouchHeartbeat(session *AgentSession) { m.registry.mu.Lock() defer m.registry.mu.Unlock() @@ -232,10 +252,7 @@ func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { if session == nil { return ErrAgentOffline } - - err := session.SendToAgent(env) - m.clearSessionAfterSendError(session, err) - return err + return session.SendToAgent(env) } func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { @@ -245,17 +262,15 @@ func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.Gateway if session == nil { return ErrAgentOffline } - - err := session.SendToAgentContext(ctx, env) - m.clearSessionAfterSendError(session, err) - return err + return session.SendToAgentContext(ctx, env) } -func (m *Manager) clearSessionAfterSendError(session *AgentSession, err error) { - if err == nil || session == nil { - return +func (m *Manager) SendToAgentOrQueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + err := m.SendToAgentContext(ctx, env) + if errors.Is(err, ErrAgentOffline) { + return m.cmdQueue.Enqueue(ctx, env) } - m.ClearSession(session) + return err } func (m *Manager) currentSessionEpoch() uint64 { diff --git a/crates/agent-gateway/internal/session/manager_settings_sync.go b/crates/agent-gateway/internal/session/manager_settings_sync.go index 34b0f5301..f5dabf444 100644 --- a/crates/agent-gateway/internal/session/manager_settings_sync.go +++ b/crates/agent-gateway/internal/session/manager_settings_sync.go @@ -8,7 +8,7 @@ import ( ) func (m *Manager) SubscribeSettingsSync() (<-chan *gatewayv1.SettingsSyncEvent, func()) { - ch := make(chan *gatewayv1.SettingsSyncEvent, 32) + ch := make(chan *gatewayv1.SettingsSyncEvent, 64) m.syncHub.settingsMu.Lock() subID := m.syncHub.nextSettingsSubID diff --git a/crates/agent-gateway/internal/session/manager_state.go b/crates/agent-gateway/internal/session/manager_state.go index 6e4aa4a15..928c76552 100644 --- a/crates/agent-gateway/internal/session/manager_state.go +++ b/crates/agent-gateway/internal/session/manager_state.go @@ -53,6 +53,7 @@ type syncHub struct { chatQueueMu sync.Mutex nextChatQueueSubID int chatQueueSubscribers map[int]chan *gatewayv1.ChatQueueEvent + chatQueueSnapshots map[string]chatQueueSnapshotRecord } func newSyncHub() *syncHub { @@ -64,26 +65,7 @@ func newSyncHub() *syncHub { terminalStreamSubscribers: make(map[int]chan *gatewayv1.TerminalStreamFrame), sftpSubscribers: make(map[int]chan *gatewayv1.SftpEvent), chatQueueSubscribers: make(map[int]chan *gatewayv1.ChatQueueEvent), + chatQueueSnapshots: make(map[string]chatQueueSnapshotRecord), } } -type chatRunStore struct { - chatCommandMu sync.Mutex - chatMu sync.Mutex - eventStore ChatEventStore - nextChatRunSubID int - nextChatRunEpoch int64 - chatRuns map[string]*chatRun - chatRunByConversation map[string]string - chatRunByClientRequest map[string]string - historyActiveRuns map[string]activeHistoryRun -} - -func newChatRunStore() *chatRunStore { - return &chatRunStore{ - chatRuns: make(map[string]*chatRun), - chatRunByConversation: make(map[string]string), - chatRunByClientRequest: make(map[string]string), - historyActiveRuns: make(map[string]activeHistoryRun), - } -} diff --git a/crates/agent-gateway/internal/session/manager_terminal.go b/crates/agent-gateway/internal/session/manager_terminal.go index 97383461f..6ccd31db7 100644 --- a/crates/agent-gateway/internal/session/manager_terminal.go +++ b/crates/agent-gateway/internal/session/manager_terminal.go @@ -213,7 +213,7 @@ func (m *Manager) TerminalSessionSnapshot(projectPathKey string) []*gatewayv1.Te return sessions } -func (m *Manager) ReplaceTerminalSessionSnapshot( +func (m *Manager) replaceTerminalSessionSnapshot( projectPathKey string, sessions []*gatewayv1.TerminalSession, ) { @@ -251,9 +251,9 @@ func (m *Manager) ApplyTerminalResponseSnapshot( switch action { case "list": - m.ReplaceTerminalSessionSnapshot(projectPathKey, resp.GetSessions()) + m.replaceTerminalSessionSnapshot(projectPathKey, resp.GetSessions()) case "close_project": - m.ReplaceTerminalSessionSnapshot(projectPathKey, nil) + m.replaceTerminalSessionSnapshot(projectPathKey, nil) case "close": if sessionID := strings.TrimSpace(resp.GetSession().GetId()); sessionID != "" { m.syncHub.terminalMu.Lock() diff --git a/crates/agent-gateway/internal/session/manager_test.go b/crates/agent-gateway/internal/session/manager_test.go index 6b4af97f1..757652901 100644 --- a/crates/agent-gateway/internal/session/manager_test.go +++ b/crates/agent-gateway/internal/session/manager_test.go @@ -2,7 +2,6 @@ package session import ( "testing" - "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -58,7 +57,7 @@ func TestApplySettingsJSONPreservingRemoteDoesNotTrustIncomingRemote(t *testing. func TestTerminalSessionSnapshotPreservesSshMetadataAndSorts(t *testing.T) { manager := NewManager() - manager.ReplaceTerminalSessionSnapshot("", []*gatewayv1.TerminalSession{ + manager.replaceTerminalSessionSnapshot("", []*gatewayv1.TerminalSession{ { Id: "ssh-2", ProjectPathKey: "/workspace/b", @@ -134,61 +133,30 @@ func TestTerminalSessionSnapshotPreservesSshMetadataAndSorts(t *testing.T) { } } -func TestAppendCappedChatRunEventKeepsLatestEvents(t *testing.T) { - var events []*ChatBroadcastEvent - for seq := int64(1); seq <= 5; seq++ { - events = appendCappedChatRunEvent(events, &ChatBroadcastEvent{Seq: seq}, 3) - } - - if len(events) != 3 { - t.Fatalf("events len = %d, want 3", len(events)) - } - if got := []int64{events[0].Seq, events[1].Seq, events[2].Seq}; got[0] != 3 || got[1] != 4 || got[2] != 5 { - t.Fatalf("events seqs = %#v, want [3 4 5]", got) - } - - events = appendCappedChatRunEvent(events, nil, 3) - if got := []int64{events[0].Seq, events[1].Seq, events[2].Seq}; got[0] != 3 || got[1] != 4 || got[2] != 5 { - t.Fatalf("nil event changed buffered seqs to %#v, want [3 4 5]", got) - } -} - -func TestChatRunShouldPruneRetainsRunningUntilStale(t *testing.T) { - now := time.Now() - running := &chatRun{ - state: ChatRunStateRunning, - updatedAt: now.Add(-(chatRunStartRetention + time.Second)), - } - if running.shouldPrune(now) { - t.Fatal("running chat should survive the start retention window") - } +func TestActiveConversationActivitiesTracksRunLifecycle(t *testing.T) { + manager := NewManager() - queued := &chatRun{ - state: ChatRunStateQueued, - updatedAt: now.Add(-(chatRunStartRetention + time.Second)), - } - if !queued.shouldPrune(now) { - t.Fatal("unstarted queued chat should prune after start retention") - } + manager.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", nil) + manager.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "started", + State: "running", + }) - done := &chatRun{ - done: true, - expiresAt: now.Add(-time.Second), + activities := manager.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-1" || activities[0].State != RunActivityRunning { + t.Fatalf("activities = %#v, want running run-1", activities) } - if !done.shouldPrune(now) { - t.Fatal("done chat should prune after expiresAt") - } -} -func TestPruneExpiredChatRunsDropsNilEntries(t *testing.T) { - manager := NewManager() - manager.chatStore.chatMu.Lock() - manager.chatStore.chatRuns["nil-run"] = nil - manager.pruneExpiredChatRunsLocked(time.Now()) - _, exists := manager.chatStore.chatRuns["nil-run"] - manager.chatStore.chatMu.Unlock() + manager.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "completed", + State: "completed", + }) - if exists { - t.Fatal("nil chat run should be deleted during pruning") + if activities := manager.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("completed run should not appear in activities, got %#v", activities) } } diff --git a/crates/agent-gateway/internal/session/manager_tunnel.go b/crates/agent-gateway/internal/session/manager_tunnel.go deleted file mode 100644 index aaf2c1712..000000000 --- a/crates/agent-gateway/internal/session/manager_tunnel.go +++ /dev/null @@ -1,843 +0,0 @@ -package session - -import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "net/url" - "strings" - "sync" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -const ( - MaxTunnelsPerAgent = 5 - MaxTunnelConnections = 20 - defaultTunnelTTLSeconds = 3600 - tunnelSlugEntropyBytes = 24 - tunnelStreamChannelDepth = 256 - tunnelAgentSendTimeout = 10 * time.Second -) - -type tunnelStore struct { - mu sync.Mutex - tunnelsByID map[string]*tunnelRecord - tunnelIDBySlug map[string]string - streams map[string]*tunnelStream -} - -type tunnelRecord struct { - id string - slug string - name string - targetURL string - publicURL string - projectPathKey string - createdAt time.Time - expiresAt time.Time - activeConnections int - closed bool -} - -type tunnelStream struct { - streamID string - tunnelID string - ch chan *gatewayv1.TunnelFrame - done chan struct{} - once sync.Once -} - -type TunnelStreamLease struct { - manager *Manager - stream *tunnelStream - tunnel *gatewayv1.TunnelSummary - once sync.Once -} - -func newTunnelStore() *tunnelStore { - return &tunnelStore{ - tunnelsByID: make(map[string]*tunnelRecord), - tunnelIDBySlug: make(map[string]string), - streams: make(map[string]*tunnelStream), - } -} - -func (l *TunnelStreamLease) Tunnel() *gatewayv1.TunnelSummary { - if l == nil || l.tunnel == nil { - return nil - } - return cloneTunnelSummary(l.tunnel) -} - -func (l *TunnelStreamLease) TunnelID() string { - if l == nil || l.stream == nil { - return "" - } - return l.stream.tunnelID -} - -func (l *TunnelStreamLease) StreamID() string { - if l == nil || l.stream == nil { - return "" - } - return l.stream.streamID -} - -func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { - if l == nil || l.stream == nil { - return nil - } - return l.stream.ch -} - -func (l *TunnelStreamLease) Done() <-chan struct{} { - if l == nil || l.stream == nil { - return nil - } - return l.stream.done -} - -func (l *TunnelStreamLease) Release() { - if l == nil { - return - } - l.once.Do(func() { - l.manager.releaseTunnelStream(l.stream) - }) -} - -func (s *tunnelStream) close() { - if s == nil { - return - } - s.once.Do(func() { - close(s.done) - }) -} - -func (s *tunnelStream) send(frame *gatewayv1.TunnelFrame) bool { - select { - case <-s.done: - return false - case s.ch <- frame: - return true - } -} - -func (m *Manager) WebTunnelsEnabled() bool { - m.syncHub.settingsSnapshotMu.RLock() - defer m.syncHub.settingsSnapshotMu.RUnlock() - - remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) - if !ok { - return false - } - enabled, ok := remote["enableWebTunnels"].(bool) - return ok && enabled -} - -func (m *Manager) ListTunnels() []*gatewayv1.TunnelSummary { - now := time.Now() - online := m.IsOnline() - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - summaries := make([]*gatewayv1.TunnelSummary, 0, len(m.tunnels.tunnelsByID)) - for _, record := range m.tunnels.tunnelsByID { - if record == nil || record.closed { - continue - } - summaries = append(summaries, tunnelSummaryLocked(record, now, online)) - } - sortTunnelSummaries(summaries) - return summaries -} - -func (m *Manager) PrepareTunnelCreate( - input *gatewayv1.TunnelControlRequest, - publicBaseURL string, -) (*gatewayv1.TunnelControlRequest, error) { - if input == nil { - return nil, errors.New("tunnel create input is required") - } - ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) - if err != nil { - return nil, err - } - now := time.Now() - var expiresAt time.Time - if ttlSeconds > 0 { - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - activeCount := 0 - for _, record := range m.tunnels.tunnelsByID { - if record == nil || record.closed || isTunnelExpired(record, now) { - continue - } - activeCount += 1 - } - if activeCount >= MaxTunnelsPerAgent { - return nil, ErrTunnelLimitExceeded - } - - id := strings.TrimSpace(input.GetTunnelId()) - if id == "" { - id = generateTunnelID() - } - if _, exists := m.tunnels.tunnelsByID[id]; exists { - return nil, fmt.Errorf("tunnel id already exists") - } - slug := strings.TrimSpace(input.GetSlug()) - if slug == "" { - for { - generated, err := generateTunnelSlug() - if err != nil { - return nil, err - } - if _, exists := m.tunnels.tunnelIDBySlug[generated]; !exists { - slug = generated - break - } - } - } else if _, exists := m.tunnels.tunnelIDBySlug[slug]; exists { - return nil, fmt.Errorf("tunnel slug already exists") - } - - publicURL := normalizeTunnelPublicURL(input.GetPublicUrl()) - if publicURL == "" { - publicURL = buildTunnelPublicURL(publicBaseURL, slug) - } - - return &gatewayv1.TunnelControlRequest{ - Action: strings.TrimSpace(input.GetAction()), - TunnelId: id, - Slug: slug, - TargetUrl: strings.TrimSpace(input.GetTargetUrl()), - Name: strings.TrimSpace(input.GetName()), - TtlSeconds: ttlSeconds, - ExpiresAt: tunnelUnix(expiresAt), - PublicUrl: publicURL, - PublicBaseUrl: strings.TrimSpace(publicBaseURL), - ProjectPathKey: strings.TrimSpace(input.GetProjectPathKey()), - }, nil -} - -func (m *Manager) PrepareTunnelUpdate( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelControlRequest, error) { - if input == nil { - return nil, errors.New("tunnel update input is required") - } - ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) - if err != nil { - return nil, err - } - now := time.Now() - var expiresAt time.Time - if input.GetExpiresAt() > 0 { - expiresAt = time.Unix(input.GetExpiresAt(), 0) - } else if ttlSeconds > 0 { - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - targetURL := strings.TrimSpace(input.GetTargetUrl()) - if targetURL == "" { - return nil, errors.New("target_url is required") - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - identifier := strings.TrimSpace(input.GetTunnelId()) - if identifier == "" { - identifier = strings.TrimSpace(input.GetSlug()) - } - if identifier == "" { - return nil, ErrTunnelNotFound - } - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - projectPathKey := strings.TrimSpace(input.GetProjectPathKey()) - if projectPathKey == "" { - projectPathKey = record.projectPathKey - } - - return &gatewayv1.TunnelControlRequest{ - Action: strings.TrimSpace(input.GetAction()), - TunnelId: record.id, - Slug: record.slug, - TargetUrl: targetURL, - Name: strings.TrimSpace(input.GetName()), - TtlSeconds: ttlSeconds, - ExpiresAt: tunnelUnix(expiresAt), - PublicUrl: record.publicURL, - ProjectPathKey: projectPathKey, - }, nil -} - -func (m *Manager) StorePreparedTunnel( - prepared *gatewayv1.TunnelControlRequest, - targetURLOverride string, -) (*gatewayv1.TunnelSummary, error) { - if prepared == nil { - return nil, errors.New("prepared tunnel is required") - } - now := time.Now() - targetURL := strings.TrimSpace(targetURLOverride) - if targetURL == "" { - targetURL = strings.TrimSpace(prepared.GetTargetUrl()) - } - if targetURL == "" { - return nil, errors.New("target_url is required") - } - var expiresAt time.Time - if prepared.GetExpiresAt() > 0 { - expiresAt = time.Unix(prepared.GetExpiresAt(), 0) - } else if prepared.GetTtlSeconds() > 0 { - ttlSeconds, err := normalizeTunnelTTL(prepared.GetTtlSeconds()) - if err != nil { - return nil, err - } - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - record := &tunnelRecord{ - id: strings.TrimSpace(prepared.GetTunnelId()), - slug: strings.TrimSpace(prepared.GetSlug()), - name: strings.TrimSpace(prepared.GetName()), - targetURL: targetURL, - publicURL: normalizeTunnelPublicURL(prepared.GetPublicUrl()), - projectPathKey: strings.TrimSpace(prepared.GetProjectPathKey()), - createdAt: now, - expiresAt: expiresAt, - } - if record.id == "" || record.slug == "" { - return nil, errors.New("prepared tunnel is missing id or slug") - } - if record.publicURL == "" { - record.publicURL = buildTunnelPublicURL(prepared.GetPublicBaseUrl(), record.slug) - } - - online := m.IsOnline() - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - if _, exists := m.tunnels.tunnelsByID[record.id]; exists { - return nil, fmt.Errorf("tunnel id already exists") - } - if _, exists := m.tunnels.tunnelIDBySlug[record.slug]; exists { - return nil, fmt.Errorf("tunnel slug already exists") - } - m.tunnels.tunnelsByID[record.id] = record - m.tunnels.tunnelIDBySlug[record.slug] = record.id - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) CreateTunnelFromAgent( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelSummary, error) { - prepared, err := m.PrepareTunnelCreate(input, input.GetPublicBaseUrl()) - if err != nil { - return nil, err - } - return m.StorePreparedTunnel(prepared, input.GetTargetUrl()) -} - -func (m *Manager) UpdateTunnelFromAgent( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelSummary, error) { - prepared, err := m.PrepareTunnelUpdate(input) - if err != nil { - return nil, err - } - return m.ApplyTunnelUpdate(&gatewayv1.TunnelSummary{ - Id: prepared.GetTunnelId(), - Slug: prepared.GetSlug(), - Name: prepared.GetName(), - TargetUrl: prepared.GetTargetUrl(), - PublicUrl: prepared.GetPublicUrl(), - ExpiresAt: prepared.GetExpiresAt(), - ProjectPathKey: prepared.GetProjectPathKey(), - }) -} - -func (m *Manager) ApplyTunnelUpdate(summary *gatewayv1.TunnelSummary) (*gatewayv1.TunnelSummary, error) { - if summary == nil { - return nil, errors.New("tunnel update summary is required") - } - identifier := strings.TrimSpace(summary.GetId()) - if identifier == "" { - identifier = strings.TrimSpace(summary.GetSlug()) - } - if identifier == "" { - return nil, ErrTunnelNotFound - } - targetURL := strings.TrimSpace(summary.GetTargetUrl()) - if targetURL == "" { - return nil, errors.New("target_url is required") - } - now := time.Now() - online := m.IsOnline() - var expiresAt time.Time - if summary.GetExpiresAt() > 0 { - expiresAt = time.Unix(summary.GetExpiresAt(), 0) - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - record.name = strings.TrimSpace(summary.GetName()) - record.targetURL = targetURL - if publicURL := normalizeTunnelPublicURL(summary.GetPublicUrl()); publicURL != "" { - record.publicURL = publicURL - } - record.projectPathKey = strings.TrimSpace(summary.GetProjectPathKey()) - record.expiresAt = expiresAt - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { - slug = strings.TrimSpace(slug) - streamID = strings.TrimSpace(streamID) - if slug == "" || streamID == "" { - return nil, ErrTunnelNotFound - } - if !m.IsOnline() { - return nil, ErrAgentOffline - } - now := time.Now() - online := true - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - tunnelID := m.tunnels.tunnelIDBySlug[slug] - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - if record.activeConnections >= MaxTunnelConnections { - return nil, ErrTunnelOverLimit - } - stream := &tunnelStream{ - streamID: streamID, - tunnelID: record.id, - ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), - done: make(chan struct{}), - } - if existing := m.tunnels.streams[streamID]; existing != nil { - existing.close() - } - m.tunnels.streams[streamID] = stream - record.activeConnections += 1 - - return &TunnelStreamLease{ - manager: m, - stream: stream, - tunnel: tunnelSummaryLocked(record, now, online), - }, nil -} - -func (m *Manager) CloseTunnel(identifier string) (*gatewayv1.TunnelSummary, error) { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return nil, ErrTunnelNotFound - } - now := time.Now() - online := m.IsOnline() - - var summary *gatewayv1.TunnelSummary - var cancelFrames []*gatewayv1.TunnelFrame - m.tunnels.mu.Lock() - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - m.tunnels.mu.Unlock() - return nil, ErrTunnelNotFound - } - record.closed = true - summary = tunnelSummaryLocked(record, now, online) - delete(m.tunnels.tunnelsByID, record.id) - delete(m.tunnels.tunnelIDBySlug, record.slug) - for streamID, stream := range m.tunnels.streams { - if stream == nil || stream.tunnelID != record.id { - continue - } - delete(m.tunnels.streams, streamID) - stream.close() - cancelFrames = append(cancelFrames, &gatewayv1.TunnelFrame{ - StreamId: stream.streamID, - TunnelId: record.id, - Slug: record.slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, - }) - } - m.tunnels.mu.Unlock() - - for _, frame := range cancelFrames { - _ = m.SendTunnelFrameToAgent(frame) - } - return summary, nil -} - -func (m *Manager) ResumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { - if input == nil { - return nil, errors.New("resume tunnel input is required") - } - now := time.Now() - online := m.IsOnline() - id := strings.TrimSpace(input.GetTunnelId()) - slug := strings.TrimSpace(input.GetSlug()) - if id == "" && slug == "" { - return nil, ErrTunnelNotFound - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - if id == "" { - id = m.tunnels.tunnelIDBySlug[slug] - } - record := m.tunnels.tunnelsByID[id] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if slug != "" && record.slug != slug { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - if targetURL := strings.TrimSpace(input.GetTargetUrl()); targetURL != "" { - record.targetURL = targetURL - } - if name := strings.TrimSpace(input.GetName()); name != "" { - record.name = name - } - if projectPathKey := strings.TrimSpace(input.GetProjectPathKey()); projectPathKey != "" { - record.projectPathKey = projectPathKey - } - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { - if frame == nil { - return errors.New("tunnel frame is required") - } - ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) - defer cancel() - return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: fmt.Sprintf("tunnel-frame-%s", strings.TrimSpace(frame.GetStreamId())), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ - TunnelFrame: frame, - }, - }) -} - -func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { - if frame == nil { - return - } - streamID := strings.TrimSpace(frame.GetStreamId()) - if streamID == "" { - return - } - m.tunnels.mu.Lock() - stream := m.tunnels.streams[streamID] - m.tunnels.mu.Unlock() - if stream == nil { - return - } - stream.send(frame) -} - -func (m *Manager) handleAgentTunnelControl( - session *AgentSession, - requestID string, - request *gatewayv1.TunnelControlRequest, -) { - if session == nil || request == nil { - return - } - response := m.handleAgentTunnelControlInner(request) - ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) - defer cancel() - _ = session.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControlResp{ - TunnelControlResp: response, - }, - }) -} - -func (m *Manager) handleAgentTunnelControlInner( - request *gatewayv1.TunnelControlRequest, -) *gatewayv1.TunnelControlResponse { - action := strings.ToLower(strings.TrimSpace(request.GetAction())) - if action == "" { - return tunnelControlError("invalid_action", "tunnel action is required") - } - switch action { - case "list": - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnels: m.ListTunnels(), - } - case "create": - tunnel, err := m.CreateTunnelFromAgent(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "update": - tunnel, err := m.UpdateTunnelFromAgent(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "close": - identifier := request.GetTunnelId() - if strings.TrimSpace(identifier) == "" { - identifier = request.GetSlug() - } - tunnel, err := m.CloseTunnel(identifier) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "resume": - tunnel, err := m.ResumeTunnel(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - default: - return tunnelControlError("invalid_action", "unsupported tunnel action") - } -} - -func (m *Manager) releaseTunnelStream(stream *tunnelStream) { - if stream == nil { - return - } - m.tunnels.mu.Lock() - if existing := m.tunnels.streams[stream.streamID]; existing == stream { - delete(m.tunnels.streams, stream.streamID) - } - if record := m.tunnels.tunnelsByID[stream.tunnelID]; record != nil && record.activeConnections > 0 { - record.activeConnections -= 1 - } - stream.close() - m.tunnels.mu.Unlock() -} - -func normalizeTunnelTTL(input uint32) (uint32, error) { - switch input { - case 0: - return 0, nil - case 900, 3600, 14400: - return input, nil - default: - return 0, errors.New("ttl_seconds must be one of 0, 900, 3600, or 14400") - } -} - -func tunnelUnix(value time.Time) int64 { - if value.IsZero() { - return 0 - } - return value.Unix() -} - -func generateTunnelID() string { - return "tun_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "") + "_" + randomURLToken(8) -} - -func generateTunnelSlug() (string, error) { - token := randomURLToken(tunnelSlugEntropyBytes) - if token == "" { - return "", errors.New("generate tunnel slug failed") - } - return token, nil -} - -func randomURLToken(byteCount int) string { - if byteCount <= 0 { - return "" - } - buf := make([]byte, byteCount) - if _, err := rand.Read(buf); err != nil { - return "" - } - return base64.RawURLEncoding.EncodeToString(buf) -} - -func normalizeTunnelPublicURL(input string) string { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "" - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - parsed.RawQuery = "" - parsed.Fragment = "" - if !strings.HasSuffix(parsed.Path, "/") { - parsed.Path += "/" - } - return parsed.String() -} - -func buildTunnelPublicURL(publicBaseURL string, slug string) string { - base := strings.TrimSpace(publicBaseURL) - if base == "" || strings.TrimSpace(slug) == "" { - return "" - } - parsed, err := url.Parse(base) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - parsed.RawQuery = "" - parsed.Fragment = "" - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/t/" + strings.TrimSpace(slug) + "/" - return parsed.String() -} - -func isTunnelExpired(record *tunnelRecord, now time.Time) bool { - return record == nil || (!record.expiresAt.IsZero() && !record.expiresAt.After(now)) -} - -func tunnelSummaryLocked(record *tunnelRecord, now time.Time, online bool) *gatewayv1.TunnelSummary { - if record == nil { - return &gatewayv1.TunnelSummary{Status: "expired"} - } - status := "active" - if record.closed || isTunnelExpired(record, now) { - status = "expired" - } else if !online { - status = "offline" - } - activeConnections := uint32(0) - if record.activeConnections > 0 { - activeConnections = uint32(record.activeConnections) - } - return &gatewayv1.TunnelSummary{ - Id: record.id, - Slug: record.slug, - Name: record.name, - TargetUrl: record.targetURL, - PublicUrl: record.publicURL, - CreatedAt: record.createdAt.Unix(), - ExpiresAt: tunnelUnix(record.expiresAt), - ActiveConnections: activeConnections, - Status: status, - ProjectPathKey: record.projectPathKey, - } -} - -func cloneTunnelSummary(summary *gatewayv1.TunnelSummary) *gatewayv1.TunnelSummary { - if summary == nil { - return nil - } - return &gatewayv1.TunnelSummary{ - Id: summary.GetId(), - Slug: summary.GetSlug(), - Name: summary.GetName(), - TargetUrl: summary.GetTargetUrl(), - PublicUrl: summary.GetPublicUrl(), - CreatedAt: summary.GetCreatedAt(), - ExpiresAt: summary.GetExpiresAt(), - ActiveConnections: summary.GetActiveConnections(), - Status: summary.GetStatus(), - ProjectPathKey: strings.TrimSpace(summary.GetProjectPathKey()), - } -} - -func sortTunnelSummaries(summaries []*gatewayv1.TunnelSummary) { - for i := 1; i < len(summaries); i++ { - current := summaries[i] - j := i - 1 - for j >= 0 && summaries[j].GetCreatedAt() > current.GetCreatedAt() { - summaries[j+1] = summaries[j] - j-- - } - summaries[j+1] = current - } -} - -func tunnelControlError(code string, message string) *gatewayv1.TunnelControlResponse { - return &gatewayv1.TunnelControlResponse{ - ErrorCode: strings.TrimSpace(code), - ErrorMessage: strings.TrimSpace(message), - } -} - -func tunnelControlErrorFor(action string, err error) *gatewayv1.TunnelControlResponse { - code := "failed" - switch { - case errors.Is(err, ErrTunnelNotFound): - code = "not_found" - case errors.Is(err, ErrTunnelExpired): - code = "expired" - case errors.Is(err, ErrTunnelLimitExceeded): - code = "limit_exceeded" - case errors.Is(err, ErrTunnelOverLimit): - code = "over_limit" - case errors.Is(err, ErrAgentOffline): - code = "agent_offline" - } - return &gatewayv1.TunnelControlResponse{ - Action: strings.TrimSpace(action), - ErrorCode: code, - ErrorMessage: err.Error(), - } -} diff --git a/crates/agent-gateway/internal/session/manager_tunnel_test.go b/crates/agent-gateway/internal/session/manager_tunnel_test.go deleted file mode 100644 index 1e147a0b3..000000000 --- a/crates/agent-gateway/internal/session/manager_tunnel_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package session - -import ( - "errors" - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func onlineTunnelTestManager() *Manager { - manager := NewManager() - manager.SetSession(NewAgentSession(AuthSnapshot{ - AgentID: "agent-a", - AgentVersion: "test", - SessionID: "session-a", - })) - return manager -} - -func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.TunnelSummary { - t.Helper() - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: name, - TtlSeconds: 3600, - PublicBaseUrl: "https://gateway.example", - }) - if err != nil { - t.Fatalf("CreateTunnelFromAgent: %v", err) - } - if tunnel.GetSlug() == "" || tunnel.GetPublicUrl() == "" { - t.Fatalf("created tunnel missing slug/public URL: %+v", tunnel) - } - return tunnel -} - -func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { - manager := onlineTunnelTestManager() - - var first *gatewayv1.TunnelSummary - for i := 0; i < MaxTunnelsPerAgent; i++ { - tunnel := createTestTunnel(t, manager, "app") - if i == 0 { - first = tunnel - } - } - - if _, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3001", - TtlSeconds: 3600, - PublicBaseUrl: "https://gateway.example", - }); !errors.Is(err, ErrTunnelLimitExceeded) { - t.Fatalf("expected ErrTunnelLimitExceeded, got %v", err) - } - - if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent { - t.Fatalf("ListTunnels returned %d tunnels, want %d", got, MaxTunnelsPerAgent) - } - - closed, err := manager.CloseTunnel(first.GetId()) - if err != nil { - t.Fatalf("CloseTunnel: %v", err) - } - if closed.GetStatus() != "expired" { - t.Fatalf("closed tunnel summary status = %q, want expired", closed.GetStatus()) - } - if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent-1 { - t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, MaxTunnelsPerAgent-1) - } -} - -func TestTunnelAcquireConnectionLimitAndRelease(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - leases := make([]*TunnelStreamLease, 0, MaxTunnelConnections) - for i := 0; i < MaxTunnelConnections; i++ { - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-"+string(rune('a'+i))) - if err != nil { - t.Fatalf("AcquireTunnel %d: %v", i, err) - } - leases = append(leases, lease) - } - if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-over-limit"); !errors.Is(err, ErrTunnelOverLimit) { - t.Fatalf("expected ErrTunnelOverLimit, got %v", err) - } - - leases[0].Release() - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-after-release") - if err != nil { - t.Fatalf("AcquireTunnel after release: %v", err) - } - lease.Release() - for _, item := range leases[1:] { - item.Release() - } - - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if got := summaries[0].GetActiveConnections(); got != 0 { - t.Fatalf("active connections after release = %d, want 0", got) - } -} - -func TestTunnelExpiredCannotBeAcquired(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - manager.tunnels.mu.Lock() - manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Now().Add(-time.Second) - manager.tunnels.mu.Unlock() - - if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-expired"); !errors.Is(err, ErrTunnelExpired) { - t.Fatalf("expected ErrTunnelExpired, got %v", err) - } - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if summaries[0].GetStatus() != "expired" { - t.Fatalf("expired tunnel status = %q, want expired", summaries[0].GetStatus()) - } -} - -func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: "app", - TtlSeconds: 0, - PublicBaseUrl: "https://gateway.example", - }) - if err != nil { - t.Fatalf("CreateTunnelFromAgent with infinite TTL: %v", err) - } - if tunnel.GetExpiresAt() != 0 { - t.Fatalf("infinite tunnel expiresAt = %d, want 0", tunnel.GetExpiresAt()) - } - if tunnel.GetStatus() != "active" { - t.Fatalf("infinite tunnel status = %q, want active", tunnel.GetStatus()) - } - - manager.tunnels.mu.Lock() - manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Time{} - manager.tunnels.mu.Unlock() - - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-infinite") - if err != nil { - t.Fatalf("AcquireTunnel for infinite tunnel: %v", err) - } - lease.Release() -} - -func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: tunnel.GetId(), - TargetUrl: "http://127.0.0.1:4000/dashboard", - Name: "dashboard", - TtlSeconds: 0, - ProjectPathKey: "project:/tmp/liveagent", - }) - if err != nil { - t.Fatalf("UpdateTunnelFromAgent: %v", err) - } - if updated.GetName() != "dashboard" { - t.Fatalf("updated name = %q, want dashboard", updated.GetName()) - } - if updated.GetTargetUrl() != "http://127.0.0.1:4000/dashboard" { - t.Fatalf("updated target = %q", updated.GetTargetUrl()) - } - if updated.GetExpiresAt() != 0 { - t.Fatalf("updated expiresAt = %d, want 0", updated.GetExpiresAt()) - } - if updated.GetProjectPathKey() != "project:/tmp/liveagent" { - t.Fatalf("updated projectPathKey = %q", updated.GetProjectPathKey()) - } - - listed := manager.ListTunnels() - if len(listed) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) - } - if listed[0].GetId() != tunnel.GetId() || listed[0].GetTargetUrl() != updated.GetTargetUrl() { - t.Fatalf("ListTunnels did not include updated tunnel: %+v", listed[0]) - } -} - -func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { - manager := onlineTunnelTestManager() - - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: "app", - TtlSeconds: 0, - PublicBaseUrl: "https://gateway.example", - ProjectPathKey: "/workspace/app", - }) - if err != nil { - t.Fatalf("CreateTunnelFromAgent: %v", err) - } - if tunnel.GetExpiresAt() != 0 { - t.Fatalf("infinite tunnel expires_at = %d, want 0", tunnel.GetExpiresAt()) - } - if tunnel.GetProjectPathKey() != "/workspace/app" { - t.Fatalf("project_path_key = %q, want /workspace/app", tunnel.GetProjectPathKey()) - } - - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if summaries[0].GetStatus() != "active" { - t.Fatalf("infinite tunnel status = %q, want active", summaries[0].GetStatus()) - } - if summaries[0].GetExpiresAt() != 0 { - t.Fatalf("listed infinite tunnel expires_at = %d, want 0", summaries[0].GetExpiresAt()) - } -} - -func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: tunnel.GetId(), - TargetUrl: "http://localhost:3000/next", - Name: "next", - TtlSeconds: 0, - ProjectPathKey: "/workspace/app", - }) - if err != nil { - t.Fatalf("UpdateTunnelFromAgent: %v", err) - } - if updated.GetTargetUrl() != "http://localhost:3000/next" { - t.Fatalf("target_url = %q, want http://localhost:3000/next", updated.GetTargetUrl()) - } - if updated.GetName() != "next" { - t.Fatalf("name = %q, want next", updated.GetName()) - } - if updated.GetExpiresAt() != 0 { - t.Fatalf("updated expires_at = %d, want 0", updated.GetExpiresAt()) - } - if updated.GetProjectPathKey() != "/workspace/app" { - t.Fatalf("project_path_key = %q, want /workspace/app", updated.GetProjectPathKey()) - } - - listed := manager.ListTunnels() - if len(listed) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) - } - if listed[0].GetTargetUrl() != "http://localhost:3000/next" { - t.Fatalf("listed target_url = %q, want http://localhost:3000/next", listed[0].GetTargetUrl()) - } -} diff --git a/crates/agent-gateway/internal/session/sqlite_chat_event_store.go b/crates/agent-gateway/internal/session/sqlite_chat_event_store.go deleted file mode 100644 index 72403e356..000000000 --- a/crates/agent-gateway/internal/session/sqlite_chat_event_store.go +++ /dev/null @@ -1,799 +0,0 @@ -package session - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - _ "modernc.org/sqlite" -) - -type ChatEventStore interface { - StartRun(input ChatRunStoreStart) (ChatRunSnapshot, bool, error) - AppendEvents(inputs []ChatRunEventAppend) error - Replay(requestID string, conversationID string, afterSeq int64, limit int) (ChatRunSnapshot, []*ChatBroadcastEvent, bool, error) - FailOpenRuns(message string) error - Close() error -} - -type ChatRunStoreStart struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - State string - CreatedAt time.Time -} - -type ChatRunEventAppend struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - RunEpoch int64 - State string - ErrorCode string - Done bool - Event *ChatBroadcastEvent - CreatedAt time.Time -} - -type sqliteChatEventStore struct { - db *sql.DB -} - -func OpenSQLiteChatEventStore(path string) (ChatEventStore, error) { - path = strings.TrimSpace(path) - if path == "" { - return nil, errors.New("chat event store path is required") - } - if path != ":memory:" { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, fmt.Errorf("create chat event store directory: %w", err) - } - } - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(4) - db.SetMaxIdleConns(4) - store := &sqliteChatEventStore{db: db} - if err := store.configure(); err != nil { - _ = db.Close() - return nil, err - } - if err := store.migrate(); err != nil { - _ = db.Close() - return nil, err - } - return store, nil -} - -func (s *sqliteChatEventStore) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} - -func (s *sqliteChatEventStore) configure() error { - if s == nil || s.db == nil { - return errors.New("chat event store is not open") - } - pragmas := []string{ - "PRAGMA busy_timeout = 5000", - "PRAGMA foreign_keys = ON", - "PRAGMA journal_mode = WAL", - "PRAGMA synchronous = NORMAL", - } - for _, pragma := range pragmas { - if _, err := s.db.Exec(pragma); err != nil { - return fmt.Errorf("configure sqlite chat event store: %w", err) - } - } - return nil -} - -func (s *sqliteChatEventStore) migrate() error { - statements := []string{ - `CREATE TABLE IF NOT EXISTS chat_runs ( - run_id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL DEFAULT '', - client_request_id TEXT NOT NULL DEFAULT '', - workdir TEXT NOT NULL DEFAULT '', - run_epoch INTEGER NOT NULL, - state TEXT NOT NULL, - error_code TEXT NOT NULL DEFAULT '', - done INTEGER NOT NULL DEFAULT 0, - latest_seq INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS chat_command_dedup ( - client_request_id TEXT PRIMARY KEY, - run_id TEXT NOT NULL, - conversation_id TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS chat_events ( - event_id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL DEFAULT '', - run_id TEXT NOT NULL, - client_request_id TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL, - seq INTEGER NOT NULL, - payload_json TEXT NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(run_id, seq) - )`, - `CREATE INDEX IF NOT EXISTS idx_chat_runs_conversation_updated ON chat_runs(conversation_id, updated_at DESC)`, - `CREATE INDEX IF NOT EXISTS idx_chat_events_run_seq ON chat_events(run_id, seq)`, - `CREATE INDEX IF NOT EXISTS idx_chat_events_conversation_seq ON chat_events(conversation_id, seq)`, - } - for _, statement := range statements { - if _, err := s.db.Exec(statement); err != nil { - return fmt.Errorf("migrate sqlite chat event store: %w", err) - } - } - return nil -} - -func (s *sqliteChatEventStore) StartRun(input ChatRunStoreStart) (ChatRunSnapshot, bool, error) { - if s == nil || s.db == nil { - return ChatRunSnapshot{}, false, errors.New("chat event store is not open") - } - input.RequestID = strings.TrimSpace(input.RequestID) - input.ConversationID = strings.TrimSpace(input.ConversationID) - input.ClientRequestID = strings.TrimSpace(input.ClientRequestID) - input.Workdir = strings.TrimSpace(input.Workdir) - input.State = normalizeChatRunState(input.State) - if input.State == "" { - input.State = ChatRunStateQueued - } - if input.RequestID == "" { - return ChatRunSnapshot{}, false, ErrChatRunNotFound - } - now := input.CreatedAt - if now.IsZero() { - now = time.Now() - } - nowMs := now.UnixMilli() - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ChatRunSnapshot{}, false, err - } - defer rollbackUnlessCommitted(tx) - - if input.ClientRequestID != "" { - snapshot, ok, err := s.lookupRunByClientRequestTx(ctx, tx, input.ClientRequestID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if ok { - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return snapshot, false, nil - } - } - - snapshot, ok, err := s.lookupRunByIDTx(ctx, tx, input.RequestID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if ok { - if input.ClientRequestID == "" && snapshot.ClientRequestID == "" && snapshot.Done { - conversationID := input.ConversationID - if conversationID == "" { - conversationID = snapshot.ConversationID - } - workdir := input.Workdir - if workdir == "" { - workdir = snapshot.Workdir - } - runEpoch, err := nextChatRunEpochTx(ctx, tx) - if err != nil { - return ChatRunSnapshot{}, false, err - } - latestSeq, err := latestConversationSeqTx(ctx, tx, conversationID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if latestSeq < snapshot.LatestSeq { - latestSeq = snapshot.LatestSeq - } - if _, err := tx.ExecContext(ctx, ` - UPDATE chat_runs - SET conversation_id = ?, workdir = ?, run_epoch = ?, state = ?, - error_code = '', done = 0, latest_seq = max(latest_seq, ?), updated_at = ? - WHERE run_id = ? - `, conversationID, workdir, runEpoch, input.State, latestSeq, nowMs, input.RequestID); err != nil { - return ChatRunSnapshot{}, false, err - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: conversationID, - Workdir: workdir, - RunEpoch: runEpoch, - FirstSeq: snapshot.FirstSeq, - LatestSeq: latestSeq, - State: input.State, - }, true, nil - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return snapshot, false, nil - } - - runEpoch, err := nextChatRunEpochTx(ctx, tx) - if err != nil { - return ChatRunSnapshot{}, false, err - } - latestSeq, err := latestConversationSeqTx(ctx, tx, input.ConversationID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_runs ( - run_id, conversation_id, client_request_id, workdir, run_epoch, - state, error_code, done, latest_seq, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, '', 0, ?, ?, ?) - `, input.RequestID, input.ConversationID, input.ClientRequestID, input.Workdir, runEpoch, input.State, latestSeq, nowMs, nowMs); err != nil { - return ChatRunSnapshot{}, false, err - } - if input.ClientRequestID != "" { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_command_dedup (client_request_id, run_id, conversation_id, created_at) - VALUES (?, ?, ?, ?) - `, input.ClientRequestID, input.RequestID, input.ConversationID, nowMs); err != nil { - return ChatRunSnapshot{}, false, err - } - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: runEpoch, - LatestSeq: latestSeq, - State: input.State, - }, true, nil -} - -func (s *sqliteChatEventStore) AppendEvents(inputs []ChatRunEventAppend) error { - if s == nil || s.db == nil { - return nil - } - validInputs := make([]ChatRunEventAppend, 0, len(inputs)) - for _, input := range inputs { - if input.Event == nil || input.Event.Seq <= 0 { - continue - } - input.RequestID = strings.TrimSpace(input.RequestID) - if input.RequestID == "" { - continue - } - validInputs = append(validInputs, input) - } - if len(validInputs) == 0 { - return nil - } - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer rollbackUnlessCommitted(tx) - - for _, input := range validInputs { - if err := s.appendEventTx(ctx, tx, input); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *sqliteChatEventStore) appendEventTx( - ctx context.Context, - tx *sql.Tx, - input ChatRunEventAppend, -) error { - input.RequestID = strings.TrimSpace(input.RequestID) - input.ConversationID = strings.TrimSpace(input.ConversationID) - input.ClientRequestID = strings.TrimSpace(input.ClientRequestID) - input.Workdir = strings.TrimSpace(input.Workdir) - input.State = normalizeChatRunState(input.State) - input.ErrorCode = strings.TrimSpace(input.ErrorCode) - if input.RequestID == "" { - return nil - } - payload := storedChatBroadcastPayload(input) - if len(payload) == 0 { - return nil - } - payloadJSON, err := json.Marshal(payload) - if err != nil { - return err - } - if input.CreatedAt.IsZero() { - input.CreatedAt = time.Now() - } - createdMs := input.CreatedAt.UnixMilli() - runID := input.RequestID - conversationID := input.ConversationID - clientRequestID := input.ClientRequestID - workdir := input.Workdir - eventType, _ := payload["type"].(string) - eventType = strings.TrimSpace(eventType) - if eventType == "" { - eventType = "message" - } - doneValue := 0 - if input.Done { - doneValue = 1 - } - - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_runs ( - run_id, conversation_id, client_request_id, workdir, run_epoch, - state, error_code, done, latest_seq, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id) DO UPDATE SET - conversation_id = excluded.conversation_id, - client_request_id = excluded.client_request_id, - workdir = excluded.workdir, - run_epoch = excluded.run_epoch, - state = excluded.state, - error_code = excluded.error_code, - done = excluded.done, - latest_seq = max(chat_runs.latest_seq, excluded.latest_seq), - updated_at = excluded.updated_at - `, runID, conversationID, clientRequestID, workdir, input.RunEpoch, input.State, input.ErrorCode, doneValue, input.Event.Seq, createdMs, createdMs); err != nil { - return err - } - if clientRequestID != "" { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_command_dedup (client_request_id, run_id, conversation_id, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(client_request_id) DO NOTHING - `, clientRequestID, runID, conversationID, createdMs); err != nil { - return err - } - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_events ( - event_id, conversation_id, run_id, client_request_id, type, seq, payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id, seq) DO NOTHING - `, fmt.Sprintf("%s/%d", runID, input.Event.Seq), conversationID, runID, clientRequestID, eventType, input.Event.Seq, string(payloadJSON), createdMs); err != nil { - return err - } - return nil -} - -func (s *sqliteChatEventStore) Replay( - requestID string, - conversationID string, - afterSeq int64, - limit int, -) (ChatRunSnapshot, []*ChatBroadcastEvent, bool, error) { - if s == nil || s.db == nil { - return ChatRunSnapshot{}, nil, false, errors.New("chat event store is not open") - } - if afterSeq < 0 { - afterSeq = 0 - } - if limit <= 0 { - limit = maxBufferedChatRunEvents - } - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return ChatRunSnapshot{}, nil, false, err - } - defer rollbackUnlessCommitted(tx) - - snapshot, ok, err := s.lookupRunTx(ctx, tx, requestID, conversationID) - if err != nil || !ok { - return ChatRunSnapshot{}, nil, ok, err - } - var rows *sql.Rows - if strings.TrimSpace(requestID) == "" && strings.TrimSpace(conversationID) != "" { - rows, err = tx.QueryContext(ctx, ` - SELECT e.run_id, e.seq, e.payload_json, COALESCE(r.workdir, '') - FROM chat_events e - LEFT JOIN chat_runs r ON r.run_id = e.run_id - WHERE e.conversation_id = ? AND e.seq > ? - ORDER BY e.seq ASC - LIMIT ? - `, snapshot.ConversationID, afterSeq, limit) - } else { - rows, err = tx.QueryContext(ctx, ` - SELECT e.run_id, e.seq, e.payload_json, COALESCE(r.workdir, '') - FROM chat_events e - LEFT JOIN chat_runs r ON r.run_id = e.run_id - WHERE e.run_id = ? AND e.seq > ? - ORDER BY e.seq ASC - LIMIT ? - `, snapshot.RequestID, afterSeq, limit) - } - if err != nil { - return ChatRunSnapshot{}, nil, false, err - } - defer rows.Close() - - events := make([]*ChatBroadcastEvent, 0) - for rows.Next() { - var runID string - var seq int64 - var payloadJSON string - var workdir string - if err := rows.Scan(&runID, &seq, &payloadJSON, &workdir); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - var payload map[string]any - if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - events = append(events, &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(runID), - Payload: payload, - Seq: seq, - Workdir: strings.TrimSpace(workdir), - }) - } - if err := rows.Err(); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - return snapshot, events, true, nil -} - -func (s *sqliteChatEventStore) FailOpenRuns(message string) error { - if s == nil || s.db == nil { - return nil - } - message = strings.TrimSpace(message) - if message == "" { - message = "Gateway restarted before the remote chat run finished. Please retry." - } - now := time.Now() - nowMs := now.UnixMilli() - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer rollbackUnlessCommitted(tx) - - rows, err := tx.QueryContext(ctx, ` - SELECT run_id, conversation_id, client_request_id, workdir, run_epoch, latest_seq - FROM chat_runs - WHERE done = 0 - `) - if err != nil { - return err - } - type openRun struct { - runID string - conversationID string - clientRequestID string - workdir string - runEpoch int64 - latestSeq int64 - } - openRuns := make([]openRun, 0) - for rows.Next() { - var run openRun - if err := rows.Scan(&run.runID, &run.conversationID, &run.clientRequestID, &run.workdir, &run.runEpoch, &run.latestSeq); err != nil { - _ = rows.Close() - return err - } - openRuns = append(openRuns, run) - } - if err := rows.Close(); err != nil { - return err - } - if err := rows.Err(); err != nil { - return err - } - - for _, run := range openRuns { - seq := run.latestSeq + 1 - payload := map[string]any{ - "type": "failed", - "request_id": run.runID, - "client_request_id": run.clientRequestID, - "conversation_id": run.conversationID, - "run_epoch": run.runEpoch, - "state": ChatRunStateFailed, - "error_code": "gateway_restarted", - "message": message, - "seq": seq, - } - if run.workdir != "" { - payload["workdir"] = run.workdir - } - payloadJSON, err := json.Marshal(payload) - if err != nil { - return err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_events ( - event_id, conversation_id, run_id, client_request_id, type, seq, payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id, seq) DO NOTHING - `, fmt.Sprintf("%s/%d", run.runID, seq), run.conversationID, run.runID, run.clientRequestID, "failed", seq, string(payloadJSON), nowMs); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, ` - UPDATE chat_runs - SET state = ?, error_code = ?, done = 1, latest_seq = max(latest_seq, ?), updated_at = ? - WHERE run_id = ? - `, ChatRunStateFailed, "gateway_restarted", seq, nowMs, run.runID); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *sqliteChatEventStore) lookupRunByClientRequestTx( - ctx context.Context, - tx *sql.Tx, - clientRequestID string, -) (ChatRunSnapshot, bool, error) { - clientRequestID = strings.TrimSpace(clientRequestID) - if clientRequestID == "" { - return ChatRunSnapshot{}, false, nil - } - var runID string - err := tx.QueryRowContext(ctx, ` - SELECT run_id FROM chat_command_dedup WHERE client_request_id = ? - `, clientRequestID).Scan(&runID) - if errors.Is(err, sql.ErrNoRows) { - return ChatRunSnapshot{}, false, nil - } - if err != nil { - return ChatRunSnapshot{}, false, err - } - snapshot, ok, err := s.lookupRunByIDTx(ctx, tx, runID) - if err != nil || ok { - return snapshot, ok, err - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM chat_command_dedup WHERE client_request_id = ? - `, clientRequestID); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{}, false, nil -} - -func (s *sqliteChatEventStore) lookupRunByIDTx( - ctx context.Context, - tx *sql.Tx, - requestID string, -) (ChatRunSnapshot, bool, error) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return ChatRunSnapshot{}, false, nil - } - return scanChatRunSnapshot(tx.QueryRowContext(ctx, chatRunSnapshotSQL(`r.run_id = ?`), requestID)) -} - -func (s *sqliteChatEventStore) lookupRunTx( - ctx context.Context, - tx *sql.Tx, - requestID string, - conversationID string, -) (ChatRunSnapshot, bool, error) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if requestID != "" { - return s.lookupRunByIDTx(ctx, tx, requestID) - } - if conversationID == "" { - return ChatRunSnapshot{}, false, nil - } - return scanChatRunSnapshot(tx.QueryRowContext(ctx, chatRunSnapshotSQL(`r.conversation_id = ? ORDER BY r.updated_at DESC LIMIT 1`), conversationID)) -} - -func chatRunSnapshotSQL(where string) string { - return fmt.Sprintf(` - SELECT - r.run_id, - r.conversation_id, - r.client_request_id, - r.workdir, - COALESCE((SELECT MIN(e.seq) FROM chat_events e WHERE e.run_id = r.run_id), 0) AS first_seq, - r.latest_seq, - r.run_epoch, - r.state, - r.error_code, - r.done - FROM chat_runs r - WHERE %s - `, where) -} - -type chatRunSnapshotScanner interface { - Scan(dest ...any) error -} - -func scanChatRunSnapshot(row chatRunSnapshotScanner) (ChatRunSnapshot, bool, error) { - var snapshot ChatRunSnapshot - var done int - err := row.Scan( - &snapshot.RequestID, - &snapshot.ConversationID, - &snapshot.ClientRequestID, - &snapshot.Workdir, - &snapshot.FirstSeq, - &snapshot.LatestSeq, - &snapshot.RunEpoch, - &snapshot.State, - &snapshot.ErrorCode, - &done, - ) - if errors.Is(err, sql.ErrNoRows) { - return ChatRunSnapshot{}, false, nil - } - if err != nil { - return ChatRunSnapshot{}, false, err - } - snapshot.State = normalizeChatRunState(snapshot.State) - snapshot.Done = done != 0 || isTerminalChatRunState(snapshot.State) - return snapshot, true, nil -} - -func nextChatRunEpochTx(ctx context.Context, tx *sql.Tx) (int64, error) { - var epoch int64 - if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(run_epoch), 0) + 1 FROM chat_runs`).Scan(&epoch); err != nil { - return 0, err - } - return epoch, nil -} - -func latestConversationSeqTx(ctx context.Context, tx *sql.Tx, conversationID string) (int64, error) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return 0, nil - } - var latestSeq int64 - if err := tx.QueryRowContext(ctx, ` - SELECT COALESCE(MAX(seq), 0) - FROM chat_events - WHERE conversation_id = ? - `, conversationID).Scan(&latestSeq); err != nil { - return 0, err - } - return latestSeq, nil -} - -func rollbackUnlessCommitted(tx *sql.Tx) { - if tx != nil { - _ = tx.Rollback() - } -} - -func storedChatBroadcastPayload(input ChatRunEventAppend) map[string]any { - event := input.Event - if event == nil { - return nil - } - var payload map[string]any - switch { - case len(event.Payload) > 0: - payload = cloneChatPayloadMap(event.Payload) - case event.Control != nil: - payload = storedChatControlPayload(event.Control) - case event.Event != nil: - payload = storedChatEventPayload(event.Event) - default: - payload = make(map[string]any) - } - if payload == nil { - payload = make(map[string]any) - } - payload["request_id"] = strings.TrimSpace(input.RequestID) - payload["client_request_id"] = strings.TrimSpace(input.ClientRequestID) - payload["conversation_id"] = strings.TrimSpace(input.ConversationID) - payload["run_epoch"] = input.RunEpoch - payload["state"] = normalizeChatRunState(input.State) - payload["seq"] = event.Seq - if workdir := strings.TrimSpace(input.Workdir); workdir != "" { - payload["workdir"] = workdir - } - if eventType, _ := payload["type"].(string); strings.TrimSpace(eventType) == "" { - payload["type"] = "message" - } else { - payload["type"] = strings.TrimSpace(eventType) - } - return payload -} - -func storedChatControlPayload(control *gatewayv1.ChatControlEvent) map[string]any { - payload := map[string]any{ - "type": strings.TrimSpace(control.GetType()), - "request_id": strings.TrimSpace(control.GetRequestId()), - "client_request_id": strings.TrimSpace(control.GetClientRequestId()), - "conversation_id": strings.TrimSpace(control.GetConversationId()), - "run_epoch": control.GetRunEpoch(), - "state": strings.TrimSpace(control.GetState()), - } - if seq := control.GetSeq(); seq > 0 { - payload["seq"] = seq - } - if errorCode := strings.TrimSpace(control.GetErrorCode()); errorCode != "" { - payload["error_code"] = errorCode - } - if message := strings.TrimSpace(control.GetMessage()); message != "" { - payload["message"] = message - } - return payload -} - -func storedChatEventPayload(event *gatewayv1.ChatEvent) map[string]any { - payload := map[string]any{ - "type": storedChatEventType(event.GetType()), - } - raw := strings.TrimSpace(event.GetData()) - if raw != "" { - var decoded map[string]any - if err := json.Unmarshal([]byte(raw), &decoded); err == nil { - for key, value := range decoded { - payload[key] = value - } - } - } - if conversationID := strings.TrimSpace(event.GetConversationId()); conversationID != "" { - payload["conversation_id"] = conversationID - } - return payload -} - -func storedChatEventType(eventType gatewayv1.ChatEvent_ChatEventType) string { - switch eventType { - case gatewayv1.ChatEvent_TOKEN: - return "token" - case gatewayv1.ChatEvent_THINKING: - return "thinking" - case gatewayv1.ChatEvent_TOOL_CALL: - return "tool_call" - case gatewayv1.ChatEvent_TOOL_RESULT: - return "tool_result" - case gatewayv1.ChatEvent_DONE: - return "done" - case gatewayv1.ChatEvent_ERROR: - return "error" - case gatewayv1.ChatEvent_TOOL_STATUS: - return "tool_status" - case gatewayv1.ChatEvent_HOSTED_SEARCH: - return "hosted_search" - case gatewayv1.ChatEvent_USER_MESSAGE: - return "user_message" - default: - return "message" - } -} diff --git a/crates/agent-gateway/internal/session/tunnel_state.go b/crates/agent-gateway/internal/session/tunnel_state.go new file mode 100644 index 000000000..f5bb8de0a --- /dev/null +++ b/crates/agent-gateway/internal/session/tunnel_state.go @@ -0,0 +1,626 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + maxTunnelsPerAgent = 5 + maxTunnelConnections = 20 + tunnelSlugEntropyBytes = 24 + tunnelStreamChannelDepth = 256 + tunnelAgentSendTimeout = 10 * time.Second + tunnelRelayProbeTimeout = 5 * time.Second + tunnelExpirySweepPeriod = 30 * time.Second +) + +var tunnelSlugPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`) + +// tunnelRuntime is the gateway-side runtime view of the agent's desired +// tunnel set: slug allocation, live streams, connection counts, and health. +// The desired specs themselves are owned and persisted by the agent. +type tunnelRuntime struct { + mu sync.Mutex + records map[string]*tunnelRecord + slugToID map[string]string + streams map[string]*tunnelStream + revision uint64 + relay *gatewayv1.TunnelHealth + + subMu sync.Mutex + nextSubID int + subscribers map[int]chan *gatewayv1.TunnelStateSnapshot + + pingMu sync.Mutex + pendingPings map[string]chan int64 +} + +type tunnelRecord struct { + id string + slug string + name string + targetURL string + projectPathKey string + createdAt time.Time + expiresAt time.Time + activeConnections int + local *gatewayv1.TunnelHealth +} + +type tunnelStream struct { + streamID string + tunnelID string + ch chan *gatewayv1.TunnelFrame + done chan struct{} + once sync.Once +} + +// TunnelStreamLease is one visitor connection's claim on a tunnel. +type TunnelStreamLease struct { + manager *Manager + stream *tunnelStream + slug string + targetURL string + once sync.Once +} + +func newTunnelRuntime() *tunnelRuntime { + return &tunnelRuntime{ + records: make(map[string]*tunnelRecord), + slugToID: make(map[string]string), + streams: make(map[string]*tunnelStream), + subscribers: make(map[int]chan *gatewayv1.TunnelStateSnapshot), + pendingPings: make(map[string]chan int64), + } +} + +func (s *tunnelStream) close() { + if s == nil { + return + } + s.once.Do(func() { + close(s.done) + }) +} + +func (l *TunnelStreamLease) TunnelID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.tunnelID +} + +func (l *TunnelStreamLease) Slug() string { + if l == nil { + return "" + } + return l.slug +} + +func (l *TunnelStreamLease) TargetURL() string { + if l == nil { + return "" + } + return l.targetURL +} + +func (l *TunnelStreamLease) StreamID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.streamID +} + +func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { + if l == nil || l.stream == nil { + return nil + } + return l.stream.ch +} + +func (l *TunnelStreamLease) Done() <-chan struct{} { + if l == nil || l.stream == nil { + return nil + } + return l.stream.done +} + +func (l *TunnelStreamLease) Release() { + if l == nil { + return + } + l.once.Do(func() { + l.manager.releaseTunnelStream(l.stream) + }) +} + +func (m *Manager) WebTunnelsEnabled() bool { + m.syncHub.settingsSnapshotMu.RLock() + defer m.syncHub.settingsSnapshotMu.RUnlock() + + remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) + if !ok { + return false + } + enabled, ok := remote["enableWebTunnels"].(bool) + return ok && enabled +} + +// ApplyDesiredState reconciles the runtime against the agent's full desired +// tunnel set: allocates slugs for new tunnels (honoring valid unused hints), +// updates changed ones, and drops removed ones (canceling their streams). +func (m *Manager) ApplyDesiredState(desired *gatewayv1.TunnelDesiredState) { + if desired == nil { + return + } + now := time.Now() + specs := desired.GetTunnels() + if len(specs) > maxTunnelsPerAgent { + specs = specs[:maxTunnelsPerAgent] + } + + var canceled []*tunnelStream + m.tunnels.mu.Lock() + seen := make(map[string]bool, len(specs)) + for _, spec := range specs { + id := strings.TrimSpace(spec.GetId()) + targetURL := strings.TrimSpace(spec.GetTargetUrl()) + if id == "" || targetURL == "" || seen[id] { + continue + } + expiresAt := time.Time{} + if spec.GetExpiresAt() > 0 { + expiresAt = time.Unix(spec.GetExpiresAt(), 0) + if !expiresAt.After(now) { + continue + } + } + seen[id] = true + record := m.tunnels.records[id] + if record == nil { + record = &tunnelRecord{ + id: id, + slug: m.allocateTunnelSlugLocked(spec.GetSlugHint()), + createdAt: now, + } + m.tunnels.records[id] = record + m.tunnels.slugToID[record.slug] = id + } + record.name = strings.TrimSpace(spec.GetName()) + record.targetURL = targetURL + record.projectPathKey = strings.TrimSpace(spec.GetProjectPathKey()) + record.expiresAt = expiresAt + } + for id, record := range m.tunnels.records { + if seen[id] { + continue + } + canceled = append(canceled, m.dropTunnelRecordLocked(record)...) + } + m.tunnels.mu.Unlock() + + m.cancelTunnelStreams(canceled) + m.broadcastTunnelState() + go m.probeRelay() +} + +// ApplyProbeReport merges agent-reported local-service health into the runtime. +func (m *Manager) ApplyProbeReport(report *gatewayv1.TunnelProbeReport) { + if report == nil || len(report.GetResults()) == 0 { + return + } + changed := false + m.tunnels.mu.Lock() + for _, result := range report.GetResults() { + record := m.tunnels.records[strings.TrimSpace(result.GetTunnelId())] + if record == nil || result.GetLocal() == nil { + continue + } + record.local = cloneTunnelHealth(result.GetLocal()) + changed = true + } + m.tunnels.mu.Unlock() + if changed { + m.broadcastTunnelState() + } +} + +// dropTunnelRecordLocked removes a record and returns its now-closed streams +// so CANCEL frames can be sent to the agent outside the lock. +func (m *Manager) dropTunnelRecordLocked(record *tunnelRecord) []*tunnelStream { + if record == nil { + return nil + } + delete(m.tunnels.records, record.id) + delete(m.tunnels.slugToID, record.slug) + var dropped []*tunnelStream + for streamID, stream := range m.tunnels.streams { + if stream == nil || stream.tunnelID != record.id { + continue + } + delete(m.tunnels.streams, streamID) + stream.close() + dropped = append(dropped, stream) + } + return dropped +} + +func (m *Manager) cancelTunnelStreams(streams []*tunnelStream) { + for _, stream := range streams { + _ = m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: stream.streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } +} + +func (m *Manager) allocateTunnelSlugLocked(hint string) string { + hint = strings.TrimSpace(hint) + if tunnelSlugPattern.MatchString(hint) { + if _, taken := m.tunnels.slugToID[hint]; !taken { + return hint + } + } + for { + slug := randomURLToken(tunnelSlugEntropyBytes) + if slug == "" { + // crypto/rand failure; fall back to a UUID-derived token. + slug = strings.ReplaceAll(uuid.NewString(), "-", "") + } + if _, taken := m.tunnels.slugToID[slug]; !taken { + return slug + } + } +} + +func randomURLToken(byteCount int) string { + if byteCount <= 0 { + return "" + } + buf := make([]byte, byteCount) + if _, err := rand.Read(buf); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +// TunnelStateSnapshot builds the authoritative state pushed to every client. +func (m *Manager) TunnelStateSnapshot() *gatewayv1.TunnelStateSnapshot { + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + return m.tunnelStateSnapshotLocked(online) +} + +func (m *Manager) tunnelStateSnapshotLocked(online bool) *gatewayv1.TunnelStateSnapshot { + tunnels := make([]*gatewayv1.TunnelStatus, 0, len(m.tunnels.records)) + for _, record := range m.tunnels.records { + tunnels = append(tunnels, &gatewayv1.TunnelStatus{ + Id: record.id, + Slug: record.slug, + Name: record.name, + TargetUrl: record.targetURL, + PublicPath: "/t/" + record.slug + "/", + CreatedAt: record.createdAt.Unix(), + ExpiresAt: unixOrZero(record.expiresAt), + ActiveConnections: uint32(max(record.activeConnections, 0)), + ProjectPathKey: record.projectPathKey, + Local: cloneTunnelHealth(record.local), + }) + } + sort.Slice(tunnels, func(i, j int) bool { + if tunnels[i].GetCreatedAt() != tunnels[j].GetCreatedAt() { + return tunnels[i].GetCreatedAt() < tunnels[j].GetCreatedAt() + } + return tunnels[i].GetId() < tunnels[j].GetId() + }) + m.tunnels.revision += 1 + return &gatewayv1.TunnelStateSnapshot{ + Tunnels: tunnels, + Revision: m.tunnels.revision, + AgentOnline: online, + Relay: cloneTunnelHealth(m.tunnels.relay), + } +} + +func (m *Manager) SubscribeTunnelState() (<-chan *gatewayv1.TunnelStateSnapshot, func()) { + ch := make(chan *gatewayv1.TunnelStateSnapshot, 16) + + m.tunnels.subMu.Lock() + subID := m.tunnels.nextSubID + m.tunnels.nextSubID += 1 + m.tunnels.subscribers[subID] = ch + m.tunnels.subMu.Unlock() + + cleanup := func() { + m.tunnels.subMu.Lock() + // Do not close the channel: broadcastTunnelState sends after copying + // subscribers, so closing can race with an in-flight send. + delete(m.tunnels.subscribers, subID) + m.tunnels.subMu.Unlock() + } + return ch, cleanup +} + +// broadcastTunnelState pushes the current snapshot to /ws subscribers and to +// the agent (which persists allocated slugs and re-emits it to the GUI). +func (m *Manager) broadcastTunnelState() { + snapshot := m.TunnelStateSnapshot() + + m.tunnels.subMu.Lock() + subscribers := make([]chan *gatewayv1.TunnelStateSnapshot, 0, len(m.tunnels.subscribers)) + for _, ch := range m.tunnels.subscribers { + subscribers = append(subscribers, ch) + } + m.tunnels.subMu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- snapshot: + default: + } + } + + // Best-effort, non-blocking: the agent only mines snapshots for allocated + // slugs and UI display, and a fresher snapshot follows every state change. + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session != nil { + _, _ = session.TrySendToAgent(&gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-state-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelState{ + TunnelState: snapshot, + }, + }) + } +} + +// AcquireTunnel claims a visitor stream slot on the tunnel behind slug. +func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { + slug = strings.TrimSpace(slug) + streamID = strings.TrimSpace(streamID) + if slug == "" || streamID == "" { + return nil, ErrTunnelNotFound + } + if !m.IsOnline() { + return nil, ErrAgentOffline + } + now := time.Now() + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + record := m.tunnels.records[m.tunnels.slugToID[slug]] + if record == nil { + return nil, ErrTunnelNotFound + } + if !record.expiresAt.IsZero() && !record.expiresAt.After(now) { + return nil, ErrTunnelExpired + } + if record.activeConnections >= maxTunnelConnections { + return nil, ErrTunnelOverLimit + } + stream := &tunnelStream{ + streamID: streamID, + tunnelID: record.id, + ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), + done: make(chan struct{}), + } + if existing := m.tunnels.streams[streamID]; existing != nil { + existing.close() + } + m.tunnels.streams[streamID] = stream + record.activeConnections += 1 + + return &TunnelStreamLease{ + manager: m, + stream: stream, + slug: record.slug, + targetURL: record.targetURL, + }, nil +} + +func (m *Manager) releaseTunnelStream(stream *tunnelStream) { + if stream == nil { + return + } + m.tunnels.mu.Lock() + if existing := m.tunnels.streams[stream.streamID]; existing == stream { + delete(m.tunnels.streams, stream.streamID) + } + if record := m.tunnels.records[stream.tunnelID]; record != nil && record.activeConnections > 0 { + record.activeConnections -= 1 + } + stream.close() + m.tunnels.mu.Unlock() +} + +func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { + if frame == nil { + return fmt.Errorf("tunnel frame is required") + } + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-frame-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ + TunnelFrame: frame, + }, + }) +} + +// dispatchTunnelFrame routes an agent frame to its visitor stream. It runs on +// the agent gRPC read loop, so it must never block: a full stream channel +// closes the stream (the visitor handler cancels) instead of waiting. +func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { + if frame == nil { + return + } + if frame.GetKind() == gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PONG { + m.resolveRelayPong(frame.GetStreamId()) + return + } + streamID := strings.TrimSpace(frame.GetStreamId()) + if streamID == "" { + return + } + m.tunnels.mu.Lock() + stream := m.tunnels.streams[streamID] + m.tunnels.mu.Unlock() + if stream == nil { + return + } + select { + case <-stream.done: + case stream.ch <- frame: + default: + m.releaseTunnelStream(stream) + go func() { + _ = m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: "tunnel stream backlog exceeded", + }) + }() + } +} + +// probeRelay measures the gateway<->agent frame path with a PING/PONG round +// trip and folds the result into the broadcast snapshot. +func (m *Manager) probeRelay() { + checkedAt := time.Now() + health := &gatewayv1.TunnelHealth{Status: "failed", CheckedAt: checkedAt.Unix()} + + if !m.IsOnline() { + health.Error = "agent offline" + m.setRelayHealth(health) + return + } + + pingID := "ping-" + uuid.NewString() + pongCh := make(chan int64, 1) + m.tunnels.pingMu.Lock() + m.tunnels.pendingPings[pingID] = pongCh + m.tunnels.pingMu.Unlock() + defer func() { + m.tunnels.pingMu.Lock() + delete(m.tunnels.pendingPings, pingID) + m.tunnels.pingMu.Unlock() + }() + + if err := m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: pingID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PING, + }); err != nil { + health.Error = err.Error() + m.setRelayHealth(health) + return + } + + timer := time.NewTimer(tunnelRelayProbeTimeout) + defer timer.Stop() + select { + case <-pongCh: + health.Status = "ok" + health.RttMs = uint32(min(time.Since(checkedAt).Milliseconds(), int64(^uint32(0)))) + case <-timer.C: + health.Error = "relay probe timed out" + } + m.setRelayHealth(health) +} + +func (m *Manager) resolveRelayPong(streamID string) { + m.tunnels.pingMu.Lock() + ch := m.tunnels.pendingPings[strings.TrimSpace(streamID)] + delete(m.tunnels.pendingPings, strings.TrimSpace(streamID)) + m.tunnels.pingMu.Unlock() + if ch != nil { + select { + case ch <- time.Now().UnixMilli(): + default: + } + } +} + +func (m *Manager) setRelayHealth(health *gatewayv1.TunnelHealth) { + m.tunnels.mu.Lock() + m.tunnels.relay = health + m.tunnels.mu.Unlock() + m.broadcastTunnelState() +} + +// onAgentSessionCleared drops live visitor streams (their frames can no longer +// be relayed) and pushes an offline snapshot; the specs stay so `/t/*` answers +// 503 instead of 404 and clients keep rendering the tunnels as offline. +func (m *Manager) onAgentSessionCleared() { + m.tunnels.mu.Lock() + for streamID, stream := range m.tunnels.streams { + delete(m.tunnels.streams, streamID) + stream.close() + } + m.tunnels.relay = nil + m.tunnels.mu.Unlock() + m.broadcastTunnelState() +} + +func (m *Manager) tunnelExpirySweepLoop() { + ticker := time.NewTicker(tunnelExpirySweepPeriod) + defer ticker.Stop() + for range ticker.C { + m.sweepExpiredTunnels(time.Now()) + } +} + +func (m *Manager) sweepExpiredTunnels(now time.Time) { + var canceled []*tunnelStream + removed := false + m.tunnels.mu.Lock() + for _, record := range m.tunnels.records { + if record.expiresAt.IsZero() || record.expiresAt.After(now) { + continue + } + canceled = append(canceled, m.dropTunnelRecordLocked(record)...) + removed = true + } + m.tunnels.mu.Unlock() + + if !removed { + return + } + m.cancelTunnelStreams(canceled) + m.broadcastTunnelState() +} + +func cloneTunnelHealth(health *gatewayv1.TunnelHealth) *gatewayv1.TunnelHealth { + if health == nil { + return nil + } + return &gatewayv1.TunnelHealth{ + Status: health.GetStatus(), + HttpStatus: health.GetHttpStatus(), + Error: health.GetError(), + CheckedAt: health.GetCheckedAt(), + RttMs: health.GetRttMs(), + } +} + +func unixOrZero(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} diff --git a/crates/agent-gateway/internal/session/tunnel_state_test.go b/crates/agent-gateway/internal/session/tunnel_state_test.go new file mode 100644 index 000000000..d6c4b3dde --- /dev/null +++ b/crates/agent-gateway/internal/session/tunnel_state_test.go @@ -0,0 +1,275 @@ +package session + +import ( + "strings" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func newTunnelTestManager(t *testing.T) *Manager { + t.Helper() + m := NewManager() + m.SetSession(NewAgentSession(AuthSnapshot{AgentID: "test-agent"})) + return m +} + +func desiredState(specs ...*gatewayv1.TunnelSpec) *gatewayv1.TunnelDesiredState { + return &gatewayv1.TunnelDesiredState{Tunnels: specs} +} + +func findTunnelStatus(snapshot *gatewayv1.TunnelStateSnapshot, id string) *gatewayv1.TunnelStatus { + for _, tunnel := range snapshot.GetTunnels() { + if tunnel.GetId() == id { + return tunnel + } + } + return nil +} + +func TestApplyDesiredStateAddUpdateRemove(t *testing.T) { + m := newTunnelTestManager(t) + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000", Name: "a"}, + &gatewayv1.TunnelSpec{Id: "tun-b", TargetUrl: "http://localhost:4000"}, + )) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 2 { + t.Fatalf("tunnels = %d, want 2", len(snapshot.GetTunnels())) + } + statusA := findTunnelStatus(snapshot, "tun-a") + if statusA == nil || statusA.GetSlug() == "" { + t.Fatalf("tun-a missing or has no slug: %#v", statusA) + } + if statusA.GetPublicPath() != "/t/"+statusA.GetSlug()+"/" { + t.Fatalf("public path = %q", statusA.GetPublicPath()) + } + slugA := statusA.GetSlug() + + // Update keeps the allocated slug; removal drops the record. + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3001", Name: "renamed"}, + )) + snapshot = m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("tunnels after removal = %d, want 1", len(snapshot.GetTunnels())) + } + statusA = findTunnelStatus(snapshot, "tun-a") + if statusA.GetSlug() != slugA { + t.Fatalf("slug changed across update: %q -> %q", slugA, statusA.GetSlug()) + } + if statusA.GetTargetUrl() != "http://localhost:3001" || statusA.GetName() != "renamed" { + t.Fatalf("update not applied: %#v", statusA) + } +} + +func TestApplyDesiredStateHonorsSlugHintAndCollision(t *testing.T) { + m := newTunnelTestManager(t) + hint := strings.Repeat("a", 32) + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000", SlugHint: hint}, + &gatewayv1.TunnelSpec{Id: "tun-b", TargetUrl: "http://localhost:4000", SlugHint: hint}, + )) + snapshot := m.TunnelStateSnapshot() + statusA := findTunnelStatus(snapshot, "tun-a") + statusB := findTunnelStatus(snapshot, "tun-b") + if statusA.GetSlug() != hint { + t.Fatalf("tun-a slug = %q, want hint %q", statusA.GetSlug(), hint) + } + if statusB.GetSlug() == hint || statusB.GetSlug() == "" { + t.Fatalf("tun-b slug should be freshly allocated, got %q", statusB.GetSlug()) + } + + // Invalid hints are ignored. + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-c", TargetUrl: "http://localhost:5000", SlugHint: "short"}, + )) + statusC := findTunnelStatus(m.TunnelStateSnapshot(), "tun-c") + if statusC.GetSlug() == "short" { + t.Fatal("invalid slug hint must not be honored") + } +} + +func TestApplyDesiredStateEnforcesTunnelCap(t *testing.T) { + m := newTunnelTestManager(t) + specs := make([]*gatewayv1.TunnelSpec, 0, maxTunnelsPerAgent+2) + for i := 0; i < maxTunnelsPerAgent+2; i++ { + specs = append(specs, &gatewayv1.TunnelSpec{ + Id: "tun-" + string(rune('a'+i)), + TargetUrl: "http://localhost:3000", + }) + } + m.ApplyDesiredState(desiredState(specs...)) + if got := len(m.TunnelStateSnapshot().GetTunnels()); got != maxTunnelsPerAgent { + t.Fatalf("tunnels = %d, want cap %d", got, maxTunnelsPerAgent) + } +} + +func TestApplyDesiredStateSkipsExpiredAndInvalidSpecs(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "expired", TargetUrl: "http://localhost:3000", ExpiresAt: time.Now().Add(-time.Minute).Unix()}, + &gatewayv1.TunnelSpec{Id: "", TargetUrl: "http://localhost:3000"}, + &gatewayv1.TunnelSpec{Id: "no-target"}, + &gatewayv1.TunnelSpec{Id: "ok", TargetUrl: "http://localhost:3000"}, + )) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 || findTunnelStatus(snapshot, "ok") == nil { + t.Fatalf("snapshot = %#v, want only \"ok\"", snapshot.GetTunnels()) + } +} + +func TestSnapshotRevisionIsMonotonic(t *testing.T) { + m := newTunnelTestManager(t) + first := m.TunnelStateSnapshot().GetRevision() + second := m.TunnelStateSnapshot().GetRevision() + if second <= first { + t.Fatalf("revision not monotonic: %d then %d", first, second) + } +} + +func TestAcquireTunnelLifecycleAndLimits(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + + if _, err := m.AcquireTunnel("missing", "s-1"); err != ErrTunnelNotFound { + t.Fatalf("acquire missing = %v, want ErrTunnelNotFound", err) + } + + leases := make([]*TunnelStreamLease, 0, maxTunnelConnections) + for i := 0; i < maxTunnelConnections; i++ { + lease, err := m.AcquireTunnel(slug, "s-"+string(rune('a'+i))) + if err != nil { + t.Fatalf("acquire %d: %v", i, err) + } + leases = append(leases, lease) + } + if _, err := m.AcquireTunnel(slug, "s-over"); err != ErrTunnelOverLimit { + t.Fatalf("over-limit acquire = %v, want ErrTunnelOverLimit", err) + } + if got := m.TunnelStateSnapshot().GetTunnels()[0].GetActiveConnections(); got != maxTunnelConnections { + t.Fatalf("active connections = %d, want %d", got, maxTunnelConnections) + } + for _, lease := range leases { + lease.Release() + } + if got := m.TunnelStateSnapshot().GetTunnels()[0].GetActiveConnections(); got != 0 { + t.Fatalf("active connections after release = %d, want 0", got) + } + + if lease, err := m.AcquireTunnel(slug, "s-again"); err != nil { + t.Fatalf("re-acquire after release: %v", err) + } else { + if lease.TargetURL() != "http://localhost:3000" { + t.Fatalf("lease target = %q", lease.TargetURL()) + } + lease.Release() + } + + m.ClearSession(mustCurrentSession(t, m)) + if _, err := m.AcquireTunnel(slug, "s-offline"); err != ErrAgentOffline { + t.Fatalf("offline acquire = %v, want ErrAgentOffline", err) + } +} + +func mustCurrentSession(t *testing.T, m *Manager) *AgentSession { + t.Helper() + m.registry.mu.RLock() + defer m.registry.mu.RUnlock() + if m.registry.session == nil { + t.Fatal("no current agent session") + } + return m.registry.session +} + +func TestDispatchTunnelFrameDropsStreamWhenBacklogged(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + lease, err := m.AcquireTunnel(slug, "s-backlog") + if err != nil { + t.Fatalf("acquire: %v", err) + } + defer lease.Release() + + for i := 0; i < tunnelStreamChannelDepth+1; i++ { + m.dispatchTunnelFrame(&gatewayv1.TunnelFrame{ + StreamId: "s-backlog", + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + }) + } + select { + case <-lease.Done(): + case <-time.After(time.Second): + t.Fatal("backlogged stream was not closed") + } +} + +func TestSweepExpiredTunnelsRemovesRecords(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "short", TargetUrl: "http://localhost:3000", ExpiresAt: time.Now().Add(30 * time.Second).Unix()}, + &gatewayv1.TunnelSpec{Id: "forever", TargetUrl: "http://localhost:4000"}, + )) + if got := len(m.TunnelStateSnapshot().GetTunnels()); got != 2 { + t.Fatalf("tunnels = %d, want 2", got) + } + m.sweepExpiredTunnels(time.Now().Add(2 * time.Minute)) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 || findTunnelStatus(snapshot, "forever") == nil { + t.Fatalf("after sweep = %#v, want only \"forever\"", snapshot.GetTunnels()) + } +} + +func TestOnAgentSessionClearedClosesStreamsAndMarksOffline(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + lease, err := m.AcquireTunnel(slug, "s-1") + if err != nil { + t.Fatalf("acquire: %v", err) + } + + m.ClearSession(mustCurrentSession(t, m)) + select { + case <-lease.Done(): + case <-time.After(time.Second): + t.Fatal("stream not closed after agent session cleared") + } + snapshot := m.TunnelStateSnapshot() + if snapshot.GetAgentOnline() { + t.Fatal("snapshot still reports agent online") + } + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("specs must survive agent disconnect, got %d", len(snapshot.GetTunnels())) + } +} + +func TestSubscribeTunnelStateReceivesBroadcasts(t *testing.T) { + m := newTunnelTestManager(t) + ch, cleanup := m.SubscribeTunnelState() + defer cleanup() + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + + select { + case snapshot := <-ch: + if findTunnelStatus(snapshot, "tun-a") == nil { + t.Fatalf("broadcast snapshot missing tunnel: %#v", snapshot.GetTunnels()) + } + case <-time.After(time.Second): + t.Fatal("no tunnel.state broadcast received") + } +} diff --git a/crates/agent-gateway/internal/session/workspace_activity.go b/crates/agent-gateway/internal/session/workspace_activity.go new file mode 100644 index 000000000..8064757ac --- /dev/null +++ b/crates/agent-gateway/internal/session/workspace_activity.go @@ -0,0 +1,146 @@ +package session + +import ( + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const workspaceActivityChannelDepth = 16 + +// workspaceActivityHub tracks which workdirs /ws clients are interested in and +// fans agent-reported activity events out to them. The union of watched +// workdirs is pushed to the agent as a declarative full set whenever it +// changes (and on agent reconnect), so the agent owns zero subscription state. +type workspaceActivityHub struct { + mu sync.Mutex + watchCounts map[string]int + nextSubID int + subscribers map[int]*workspaceActivitySubscriber +} + +type workspaceActivitySubscriber struct { + workdir string + ch chan *gatewayv1.WorkspaceActivityEvent +} + +func newWorkspaceActivityHub() *workspaceActivityHub { + return &workspaceActivityHub{ + watchCounts: make(map[string]int), + subscribers: make(map[int]*workspaceActivitySubscriber), + } +} + +// SubscribeWorkspaceActivity registers interest in one workdir. The returned +// cleanup drops the subscription; when the workdir's refcount reaches zero it +// leaves the agent-side watch set on the next push. +func (m *Manager) SubscribeWorkspaceActivity( + workdir string, +) (<-chan *gatewayv1.WorkspaceActivityEvent, func()) { + workdir = strings.TrimSpace(workdir) + sub := &workspaceActivitySubscriber{ + workdir: workdir, + ch: make(chan *gatewayv1.WorkspaceActivityEvent, workspaceActivityChannelDepth), + } + + m.workspaceHub.mu.Lock() + subID := m.workspaceHub.nextSubID + m.workspaceHub.nextSubID += 1 + m.workspaceHub.subscribers[subID] = sub + m.workspaceHub.watchCounts[workdir] += 1 + watchSetChanged := m.workspaceHub.watchCounts[workdir] == 1 + m.workspaceHub.mu.Unlock() + + if watchSetChanged { + m.pushWorkspaceWatchSet() + } + + var once sync.Once + cleanup := func() { + once.Do(func() { + m.workspaceHub.mu.Lock() + // Do not close the channel: broadcastWorkspaceActivity sends after + // copying subscribers, so closing can race with an in-flight send. + delete(m.workspaceHub.subscribers, subID) + changed := false + if count := m.workspaceHub.watchCounts[workdir]; count > 1 { + m.workspaceHub.watchCounts[workdir] = count - 1 + } else { + delete(m.workspaceHub.watchCounts, workdir) + changed = true + } + m.workspaceHub.mu.Unlock() + if changed { + m.pushWorkspaceWatchSet() + } + }) + } + return sub.ch, cleanup +} + +// broadcastWorkspaceActivity fans one agent event out to the subscribers of +// its workdir. Runs on the agent gRPC read loop, so it must never block: a +// full subscriber channel drops the event (consumers converge on the next +// one, and revision gaps are already tolerated client-side). +func (m *Manager) broadcastWorkspaceActivity(event *gatewayv1.WorkspaceActivityEvent) { + if event == nil { + return + } + workdir := strings.TrimSpace(event.GetWorkdir()) + if workdir == "" { + return + } + + m.workspaceHub.mu.Lock() + targets := make([]chan *gatewayv1.WorkspaceActivityEvent, 0, len(m.workspaceHub.subscribers)) + for _, sub := range m.workspaceHub.subscribers { + if sub.workdir == workdir { + targets = append(targets, sub.ch) + } + } + m.workspaceHub.mu.Unlock() + + for _, ch := range targets { + select { + case ch <- event: + default: + } + } +} + +func (m *Manager) hasWorkspaceWatchInterest() bool { + m.workspaceHub.mu.Lock() + defer m.workspaceHub.mu.Unlock() + return len(m.workspaceHub.watchCounts) > 0 +} + +// pushWorkspaceWatchSet sends the full watched-workdir set to the agent. +// Best-effort and non-blocking: the set is re-pushed on every change and on +// agent reconnect, so a dropped push heals itself. +func (m *Manager) pushWorkspaceWatchSet() { + m.workspaceHub.mu.Lock() + workdirs := make([]string, 0, len(m.workspaceHub.watchCounts)) + for workdir := range m.workspaceHub.watchCounts { + workdirs = append(workdirs, workdir) + } + m.workspaceHub.mu.Unlock() + sort.Strings(workdirs) + + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session == nil { + return + } + _, _ = session.TrySendToAgent(&gatewayv1.GatewayEnvelope{ + RequestId: "workspace-watch-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_WorkspaceWatch{ + WorkspaceWatch: &gatewayv1.WorkspaceWatchRequest{Workdirs: workdirs}, + }, + }) +} diff --git a/crates/agent-gateway/internal/session/workspace_activity_test.go b/crates/agent-gateway/internal/session/workspace_activity_test.go new file mode 100644 index 000000000..8ad3dde6c --- /dev/null +++ b/crates/agent-gateway/internal/session/workspace_activity_test.go @@ -0,0 +1,187 @@ +package session + +import ( + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// newWorkspaceTestManager builds a manager with a live session. SetSession +// replays the watch set only when it is non-empty, so no async push races the +// assertions below: every push comes from a synchronous subscribe/unsubscribe +// call. +func newWorkspaceTestManager(t *testing.T) (*Manager, *AgentSession) { + t.Helper() + m := NewManager() + session := NewAgentSession(AuthSnapshot{AgentID: "test-agent"}) + m.SetSession(session) + assertNoWorkspaceWatchPush(t, session) + return m, session +} + +// awaitWorkspaceWatchSet blocks until the next WorkspaceWatchRequest reaches +// the agent outbound queue and returns its workdir set. +func awaitWorkspaceWatchSet(t *testing.T, session *AgentSession) []string { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case env := <-session.Outbound(): + if watch := env.GetWorkspaceWatch(); watch != nil { + return watch.GetWorkdirs() + } + case <-deadline: + t.Fatal("timed out waiting for a workspace watch push") + return nil + } + } +} + +// assertNoWorkspaceWatchPush fails when a WorkspaceWatchRequest is already +// queued on the agent outbound channel. +func assertNoWorkspaceWatchPush(t *testing.T, session *AgentSession) { + t.Helper() + for { + select { + case env := <-session.Outbound(): + if watch := env.GetWorkspaceWatch(); watch != nil { + t.Fatalf("unexpected workspace watch push: %v", watch.GetWorkdirs()) + } + default: + return + } + } +} + +func workspaceActivityEvent(workdir string, revision uint64) *gatewayv1.WorkspaceActivityEvent { + return &gatewayv1.WorkspaceActivityEvent{ + Workdir: workdir, + Revision: revision, + Fs: true, + Git: true, + } +} + +func TestSubscribeWorkspaceActivityPushesWatchSetWithRefcount(t *testing.T) { + m, session := newWorkspaceTestManager(t) + + _, cleanupA1 := m.SubscribeWorkspaceActivity("/repo/a") + if set := awaitWorkspaceWatchSet(t, session); len(set) != 1 || set[0] != "/repo/a" { + t.Fatalf("watch set after first subscribe = %v, want [/repo/a]", set) + } + + // Second subscriber on the same workdir must not re-push the set. + _, cleanupA2 := m.SubscribeWorkspaceActivity("/repo/a") + assertNoWorkspaceWatchPush(t, session) + + _, cleanupB := m.SubscribeWorkspaceActivity("/repo/b") + set := awaitWorkspaceWatchSet(t, session) + if len(set) != 2 || set[0] != "/repo/a" || set[1] != "/repo/b" { + t.Fatalf("watch set after second workdir = %v, want [/repo/a /repo/b]", set) + } + + // Dropping one of two /repo/a subscribers keeps the workdir watched. + cleanupA1() + assertNoWorkspaceWatchPush(t, session) + + // Cleanup is idempotent: replaying it must not decrement again. + cleanupA1() + assertNoWorkspaceWatchPush(t, session) + + // The last /repo/a subscriber leaving removes the key. + cleanupA2() + if set := awaitWorkspaceWatchSet(t, session); len(set) != 1 || set[0] != "/repo/b" { + t.Fatalf("watch set after refcount reached zero = %v, want [/repo/b]", set) + } + + cleanupB() + if set := awaitWorkspaceWatchSet(t, session); len(set) != 0 { + t.Fatalf("watch set after last unsubscribe = %v, want []", set) + } +} + +func TestSetSessionReplaysNonEmptyWorkspaceActivityWatchSet(t *testing.T) { + m, session := newWorkspaceTestManager(t) + + _, cleanup := m.SubscribeWorkspaceActivity("/repo/a") + defer cleanup() + if set := awaitWorkspaceWatchSet(t, session); len(set) != 1 || set[0] != "/repo/a" { + t.Fatalf("watch set after subscribe = %v, want [/repo/a]", set) + } + + // A reconnected agent starts blank and must learn the watch set again. + replacement := NewAgentSession(AuthSnapshot{AgentID: "test-agent"}) + m.SetSession(replacement) + if set := awaitWorkspaceWatchSet(t, replacement); len(set) != 1 || set[0] != "/repo/a" { + t.Fatalf("replayed watch set = %v, want [/repo/a]", set) + } +} + +func TestBroadcastWorkspaceActivityFiltersByWorkdir(t *testing.T) { + m, _ := newWorkspaceTestManager(t) + + eventsA, cleanupA := m.SubscribeWorkspaceActivity("/repo/a") + defer cleanupA() + eventsB, cleanupB := m.SubscribeWorkspaceActivity("/repo/b") + defer cleanupB() + + m.broadcastWorkspaceActivity(workspaceActivityEvent("/repo/a", 1)) + m.broadcastWorkspaceActivity(workspaceActivityEvent("/repo/missing", 2)) + + select { + case event := <-eventsA: + if event.GetWorkdir() != "/repo/a" || event.GetRevision() != 1 { + t.Fatalf("unexpected event on /repo/a subscriber: %#v", event) + } + case <-time.After(time.Second): + t.Fatal("subscriber for /repo/a did not receive its event") + } + + select { + case event := <-eventsA: + t.Fatalf("subscriber for /repo/a received foreign event: %#v", event) + case event := <-eventsB: + t.Fatalf("subscriber for /repo/b received foreign event: %#v", event) + default: + } +} + +func TestBroadcastWorkspaceActivityDoesNotBlockOnSlowSubscriber(t *testing.T) { + m, _ := newWorkspaceTestManager(t) + + // Never read from the channel: once its buffer is full, broadcasts must + // drop instead of blocking. + _, cleanup := m.SubscribeWorkspaceActivity("/repo/a") + defer cleanup() + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < workspaceActivityChannelDepth*3; i++ { + m.broadcastWorkspaceActivity(workspaceActivityEvent("/repo/a", uint64(i+1))) + } + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("broadcastWorkspaceActivity blocked on a slow subscriber") + } +} + +func TestBroadcastWorkspaceActivityIgnoresNilAndEmptyWorkdir(t *testing.T) { + m, _ := newWorkspaceTestManager(t) + + events, cleanup := m.SubscribeWorkspaceActivity("/repo/a") + defer cleanup() + + m.broadcastWorkspaceActivity(nil) + m.broadcastWorkspaceActivity(workspaceActivityEvent(" ", 1)) + + select { + case event := <-events: + t.Fatalf("unexpected event delivered: %#v", event) + default: + } +} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 1e2934c19..dc25e42e4 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -64,12 +64,17 @@ message GatewayEnvelope { FsReadEditableTextRequest fs_read_editable_text = 62; FsReadWorkspaceImageRequest fs_read_workspace_image = 63; SftpRequest sftp_request = 64; - TunnelControlRequest tunnel_control = 67; - TunnelControlResponse tunnel_control_resp = 68; - TunnelFrame tunnel_frame = 69; SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; ChatQueueRequest chat_queue = 73; + TunnelStateSnapshot tunnel_state = 80; + TunnelMutation tunnel_mutation = 81; + TunnelFrame tunnel_frame = 82; + WorkspaceWatchRequest workspace_watch = 90; } + + // Legacy tunnel control/frame payloads (pre-rewrite protocol) and the + // never-wired chat event replay request. + reserved 67, 68, 69, 74; } message AgentEnvelope { @@ -120,14 +125,21 @@ message AgentEnvelope { SftpEvent sftp_event = 74; ChatQueueResponse chat_queue_resp = 75; ChatQueueEvent chat_queue_event = 76; - TunnelControlRequest tunnel_control = 67; - TunnelControlResponse tunnel_control_resp = 68; - TunnelFrame tunnel_frame = 69; ChatControlEvent chat_control = 70; RuntimeStatusEvent runtime_status = 71; SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; + ChatRuntimeSnapshot chat_runtime_snapshot = 77; + TunnelDesiredState tunnel_desired = 80; + TunnelMutationResult tunnel_mutation_result = 81; + TunnelFrame tunnel_frame = 82; + TunnelProbeReport tunnel_probe_report = 83; + WorkspaceActivityEvent workspace_activity = 90; ErrorResponse error = 99; } + + // Legacy tunnel control/frame payloads (pre-rewrite protocol) and the + // never-wired chat event replay response. + reserved 67, 68, 69, 78; } message ChatSelectedModel { @@ -176,40 +188,82 @@ message UploadedImagePreviewResponse { string data = 2; } -message TunnelControlRequest { - string action = 1; - string tunnel_id = 2; - string slug = 3; - string target_url = 4; - string name = 5; - uint32 ttl_seconds = 6; - int64 expires_at = 7; - string public_url = 8; - string public_base_url = 9; - string project_path_key = 10; +// ---- Tunnel desired state (agent -> gateway) ---- + +message TunnelSpec { + string id = 1; // agent-generated, stable across restarts + string slug_hint = 2; // last allocated slug; gateway honors when valid and unused + string name = 3; + string target_url = 4; // http://localhost:PORT[/base] + int64 expires_at = 5; // unix seconds, 0 = never + string project_path_key = 6; } -message TunnelControlResponse { - string action = 1; - repeated TunnelSummary tunnels = 2; - TunnelSummary tunnel = 3; - string error_code = 4; - string error_message = 5; +message TunnelDesiredState { + repeated TunnelSpec tunnels = 1; + uint64 revision = 2; // agent-side monotonic +} + +// ---- Tunnel runtime snapshot (gateway -> agent, mirrored to /ws JSON) ---- + +message TunnelHealth { + string status = 1; // "ok" | "failed" | "unknown" + uint32 http_status = 2; // local layer only + string error = 3; + int64 checked_at = 4; + uint32 rtt_ms = 5; // relay layer only } -message TunnelSummary { +message TunnelStatus { string id = 1; string slug = 2; string name = 3; string target_url = 4; - string public_url = 5; + string public_path = 5; // "/t/{slug}/"; clients compose the full URL int64 created_at = 6; int64 expires_at = 7; uint32 active_connections = 8; - string status = 9; - string project_path_key = 10; + string project_path_key = 9; + TunnelHealth local = 10; // agent -> local service reachability +} + +message TunnelStateSnapshot { + repeated TunnelStatus tunnels = 1; + uint64 revision = 2; // gateway-side monotonic + bool agent_online = 3; + TunnelHealth relay = 4; // gateway <-> agent frame path +} + +// ---- Tunnel mutations forwarded gateway -> agent (webui-originated) ---- + +message TunnelMutation { + string action = 1; // "create" | "update" | "close" | "check" + string tunnel_id = 2; // update/close/check + string target_url = 3; + string name = 4; + optional uint32 ttl_seconds = 5; // absent on update = keep current expiry + string project_path_key = 6; +} + +message TunnelMutationResult { + string tunnel_id = 1; + string error_code = 2; // "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + string error_message = 3; +} + +// ---- Tunnel local-service probe results (agent -> gateway) ---- + +message TunnelProbeResult { + string tunnel_id = 1; + TunnelHealth local = 2; } +message TunnelProbeReport { + repeated TunnelProbeResult results = 1; +} + +// ---- Tunnel data plane ---- + message TunnelHeader { string name = 1; string value = 2; @@ -223,26 +277,53 @@ enum TunnelFrameKind { TUNNEL_FRAME_KIND_HTTP_RESPONSE_START = 4; TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY = 5; TUNNEL_FRAME_KIND_HTTP_RESPONSE_END = 6; - TUNNEL_FRAME_KIND_WS_OPEN = 7; - TUNNEL_FRAME_KIND_WS_FRAME = 8; - TUNNEL_FRAME_KIND_WS_CLOSE = 9; - TUNNEL_FRAME_KIND_ERROR = 10; - TUNNEL_FRAME_KIND_CANCEL = 11; + TUNNEL_FRAME_KIND_WS_DIAL = 7; + TUNNEL_FRAME_KIND_WS_DIAL_OK = 8; + TUNNEL_FRAME_KIND_WS_DIAL_ERROR = 9; + TUNNEL_FRAME_KIND_WS_FRAME = 10; + TUNNEL_FRAME_KIND_WS_CLOSE = 11; + TUNNEL_FRAME_KIND_ERROR = 12; + TUNNEL_FRAME_KIND_CANCEL = 13; + TUNNEL_FRAME_KIND_PING = 14; + TUNNEL_FRAME_KIND_PONG = 15; +} + +enum TunnelWsMessageType { + TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED = 0; + TUNNEL_WS_MESSAGE_TYPE_TEXT = 1; + TUNNEL_WS_MESSAGE_TYPE_BINARY = 2; } message TunnelFrame { string stream_id = 1; - string tunnel_id = 2; - string slug = 3; - TunnelFrameKind kind = 4; - string method = 5; - string path = 6; - repeated TunnelHeader headers = 7; - uint32 status_code = 8; - bytes body = 9; - bool end_stream = 10; - string error = 11; - string ws_message_type = 12; + TunnelFrameKind kind = 2; + string target_url = 3; // set on HTTP_REQUEST_START and WS_DIAL only + string method = 4; + string path = 5; // path+query relative to the target base + repeated TunnelHeader headers = 6; + uint32 status = 7; + bytes body = 8; + string error = 9; + TunnelWsMessageType ws_message_type = 10; + string ws_subprotocol = 11; + uint32 ws_close_code = 12; + string ws_close_reason = 13; +} + +// ---- Workspace activity watch (gateway -> agent declarative full set, +// ---- agent -> gateway debounced change events) ---- + +message WorkspaceWatchRequest { + repeated string workdirs = 1; // full desired set; replaces the previous one +} + +message WorkspaceActivityEvent { + string workdir = 1; + uint64 revision = 2; // per-workdir monotonic within one agent process + bool fs = 3; // working-tree content changed + bool git = 4; // git state (HEAD/refs/index/...) changed + repeated string changed_paths = 5; // relative to workdir, deduped, capped + bool truncated = 6; // changed_paths hit the cap or is unknown } message MemoryManageRequest { @@ -535,12 +616,37 @@ message ChatControlEvent { int64 seq = 9; } +message ChatRuntimeSnapshot { + string conversation_id = 1; + string run_id = 2; + string client_request_id = 3; + string worker_id = 4; + string state = 5; + string cwd = 6; + int64 updated_at = 7; + int64 revision = 8; + string entries_json = 9; + string tool_status = 10; + bool tool_status_is_compaction = 11; +} + message RuntimeStatusEvent { string worker_id = 1; string state = 2; bool visible = 3; uint32 active_run_count = 4; int64 timestamp = 5; + repeated ChatRunReport active_runs = 6; + repeated ChatRunReport finished_runs = 7; +} + +message ChatRunReport { + string run_id = 1; + string conversation_id = 2; + string state = 3; + string error_code = 4; + string message = 5; + int64 updated_at = 6; } message CronManageRequest { diff --git a/crates/agent-gateway/test/session/chat_event_store_test.go b/crates/agent-gateway/test/session/chat_event_store_test.go deleted file mode 100644 index 8726d1386..000000000 --- a/crates/agent-gateway/test/session/chat_event_store_test.go +++ /dev/null @@ -1,550 +0,0 @@ -package session_test - -import ( - "path/filepath" - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -func newPersistentTestSessionManager(t *testing.T, path string) (*session.Manager, session.ChatEventStore) { - t.Helper() - store, err := session.OpenSQLiteChatEventStore(path) - if err != nil { - t.Fatalf("OpenSQLiteChatEventStore: %v", err) - } - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - return sm, store -} - -type staleReplayChatEventStore struct { - snapshot session.ChatRunSnapshot - replay []*session.ChatBroadcastEvent -} - -func readChatBroadcastPayloadType( - t *testing.T, - ch <-chan *session.ChatBroadcastEvent, - label string, -) string { - t.Helper() - select { - case event := <-ch: - if event.Payload != nil { - eventType, _ := event.Payload["type"].(string) - return eventType - } - if event.Control != nil { - return event.Control.GetType() - } - if event.Event != nil { - return event.Event.GetType().String() - } - return "" - case <-time.After(time.Second): - t.Fatalf("timed out waiting for %s", label) - } - return "" -} - -func (s *staleReplayChatEventStore) StartRun(input session.ChatRunStoreStart) (session.ChatRunSnapshot, bool, error) { - return session.ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: 1, - State: session.ChatRunStateQueued, - }, true, nil -} - -func (s *staleReplayChatEventStore) AppendEvents([]session.ChatRunEventAppend) error { - return nil -} - -func (s *staleReplayChatEventStore) Replay( - string, - string, - int64, - int, -) (session.ChatRunSnapshot, []*session.ChatBroadcastEvent, bool, error) { - return s.snapshot, s.replay, true, nil -} - -func (s *staleReplayChatEventStore) FailOpenRuns(string) error { - return nil -} - -func (s *staleReplayChatEventStore) Close() error { - return nil -} - -func TestSQLiteChatEventStoreReplaysCompletedRunAndDedupesCommand(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - snapshot, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || snapshot.RequestID != "request-1" { - t.Fatalf("created snapshot = %#v created=%v", snapshot, created) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "hello", - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"hi"}`, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - duplicate, created, err := next.StartPendingChatCommandRun( - "request-2", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun duplicate: %v", err) - } - if created || duplicate.RequestID != "request-1" || duplicate.LatestSeq != 4 { - t.Fatalf("duplicate snapshot = %#v created=%v, want original completed run", duplicate, created) - } - - ch, _, cleanup, replaySnapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - if replaySnapshot.RequestID != "request-1" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("replay snapshot = %#v", replaySnapshot) - } - - gotTypes := make([]string, 0, 4) - for len(gotTypes) < 4 { - select { - case event := <-ch: - eventType, _ := event.Payload["type"].(string) - gotTypes = append(gotTypes, eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for replay, got types %#v", gotTypes) - } - } - want := []string{"accepted", "user_message", "token", "done"} - if len(gotTypes) != len(want) { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, want) - } - for index := range want { - if gotTypes[index] != want[index] { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, want) - } - } -} - -func TestSQLiteHistoryRunningCreatesAndReopensAttachableConversationRun(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - defer store.Close() - - dispatchRunning := func() { - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-running", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, - }, - }) - } - - dispatchRunning() - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun first running: %v", err) - } - if snapshot.RequestID != "conversation-live-conversation-1" || - snapshot.State != session.ChatRunStateRunning || - snapshot.Workdir != "/workspace" { - t.Fatalf("first running snapshot = %#v", snapshot) - } - assertDoneOpen(t, done) - select { - case event := <-ch: - t.Fatalf("unexpected first replay before token: %#v", event) - default: - } - cleanup() - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "conversation-live-conversation-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - - dispatchRunning() - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].RequestID != "conversation-live-conversation-1" || - summaries[0].FirstSeq != 2 || - summaries[0].LatestSeq != 1 { - t.Fatalf("second active summaries = %#v", summaries) - } - - replayCh, replayDone, replayCleanup, replaySnapshot, err := sm.SubscribeChatRun( - "", - "conversation-1", - summaries[0].FirstSeq-1, - ) - if err != nil { - t.Fatalf("SubscribeChatRun second running: %v", err) - } - defer replayCleanup() - assertDoneOpen(t, replayDone) - if replaySnapshot.State != session.ChatRunStateRunning || replaySnapshot.LatestSeq != 1 { - t.Fatalf("second running snapshot = %#v", replaySnapshot) - } - select { - case event := <-replayCh: - t.Fatalf("unexpected replay from previous turn: %#v", event) - default: - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "conversation-live-conversation-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"next"}`, - }, - }, - }) - select { - case event := <-replayCh: - if event.Seq != 2 || event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("second live event = %#v, want token seq 2", event) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for second live token") - } -} - -func TestSQLiteChatEventStoreDoesNotPersistToolCallDeltaPayloads(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun live: %v", err) - } - defer cleanup() - - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "tool_call_delta", - "id": "call-write", - "name": "Write", - "arguments": map[string]any{"path": "src/app.ts", "content": "con"}, - }) - - if got := readChatBroadcastPayloadType(t, ch, "live accepted"); got != "accepted" { - t.Fatalf("live event type = %q, want accepted", got) - } - if got := readChatBroadcastPayloadType(t, ch, "live tool_call_delta"); got != "tool_call_delta" { - t.Fatalf("live event type = %q, want tool_call_delta", got) - } - - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "tool_call", - "id": "call-write", - "name": "Write", - "arguments": map[string]any{"path": "src/app.ts", "content": "console.log(1);\n"}, - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - replayCh, _, replayCleanup, replaySnapshot, err := next.SubscribeChatRun( - "request-1", - "conversation-1", - 0, - ) - if err != nil { - t.Fatalf("SubscribeChatRun replay: %v", err) - } - defer replayCleanup() - if replaySnapshot.LatestSeq != 4 { - t.Fatalf("replay latest seq = %d, want 4", replaySnapshot.LatestSeq) - } - - gotTypes := []string{ - readChatBroadcastPayloadType(t, replayCh, "replayed accepted"), - readChatBroadcastPayloadType(t, replayCh, "replayed tool_call"), - readChatBroadcastPayloadType(t, replayCh, "replayed completed"), - } - wantTypes := []string{"accepted", "tool_call", "completed"} - for index := range wantTypes { - if gotTypes[index] != wantTypes[index] { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, wantTypes) - } - } -} - -func TestSubscribeChatRunMergesStalePersistedReplayWithBufferedEvents(t *testing.T) { - store := &staleReplayChatEventStore{} - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "hello", - }) - store.snapshot = session.ChatRunSnapshot{ - RequestID: "request-1", - ConversationID: "conversation-1", - ClientRequestID: "client-submit-1", - RunEpoch: 1, - LatestSeq: 1, - State: session.ChatRunStateQueued, - } - store.replay = []*session.ChatBroadcastEvent{ - { - RequestID: "request-1", - Seq: 1, - Payload: map[string]any{ - "type": "accepted", - "conversation_id": "conversation-1", - }, - }, - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - gotTypes := make([]string, 0, 2) - for len(gotTypes) < 2 { - select { - case event := <-ch: - eventType := "" - if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } else if event.Control != nil { - eventType = event.Control.GetType() - } - gotTypes = append(gotTypes, eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for merged replay, got %#v", gotTypes) - } - } - want := []string{"accepted", "user_message"} - for index := range want { - if gotTypes[index] != want[index] { - t.Fatalf("merged replay types = %#v, want %#v", gotTypes, want) - } - } -} - -func TestSQLiteChatEventStoreContinuesConversationSeqAcrossRuns(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - snapshot, created, err := next.StartPendingChatCommandRun( - "request-2", - "conversation-1", - "client-submit-2", - ) - if err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - if snapshot.LatestSeq != 3 { - t.Fatalf("second run initial snapshot = %#v, want latest seq 3", snapshot) - } - next.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - ch, _, cleanup, replaySnapshot, err := next.SubscribeChatRun("request-2", "conversation-1", 3) - if err != nil { - t.Fatalf("SubscribeChatRun request-2: %v", err) - } - defer cleanup() - if replaySnapshot.LatestSeq != 4 { - t.Fatalf("second replay snapshot = %#v, want latest seq 4", replaySnapshot) - } - select { - case event := <-ch: - if event.Seq != 4 { - t.Fatalf("second run accepted seq = %d, want 4", event.Seq) - } - eventType, _ := event.Payload["type"].(string) - if eventType != "accepted" { - t.Fatalf("second run event type = %q, want accepted", eventType) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for second run accepted event") - } - - conversationCh, _, conversationCleanup, conversationSnapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer conversationCleanup() - if conversationSnapshot.RequestID != "request-2" || conversationSnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", conversationSnapshot) - } - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-conversationCh: - eventType, _ := event.Payload["type"].(string) - got = append(got, event.RequestID+":"+eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - want := []string{ - "request-1:accepted", - "request-1:user_message", - "request-1:completed", - "request-2:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation replay = %#v, want %#v", got, want) - } - } -} - -func TestSQLiteChatEventStoreFailsOpenRunsOnManagerStartup(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - ch, _, cleanup, snapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - if snapshot.State != session.ChatRunStateFailed || !snapshot.Done || snapshot.LatestSeq != 2 { - t.Fatalf("snapshot after restart = %#v, want failed terminal run", snapshot) - } - - gotTypes := make([]string, 0, 2) - for len(gotTypes) < 2 { - select { - case event := <-ch: - eventType, _ := event.Payload["type"].(string) - gotTypes = append(gotTypes, eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for failed replay, got %#v", gotTypes) - } - } - if gotTypes[0] != "accepted" || gotTypes[1] != "failed" { - t.Fatalf("replayed event types = %#v, want accepted then failed", gotTypes) - } -} diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 820a5e9f0..6657d7bcb 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "sync" "testing" "time" @@ -19,26 +18,6 @@ func newTestSessionManager() *session.Manager { return sm } -func startRunningChatCommandRun( - t *testing.T, - sm *session.Manager, - requestID string, - conversationID string, -) session.ChatRunSnapshot { - t.Helper() - snapshot, created, err := sm.StartPendingChatCommandRun(requestID, conversationID, "") - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created { - t.Fatalf("StartPendingChatCommandRun created = false for %q", requestID) - } - dispatchChatControl(sm, requestID, conversationID, "started", session.ChatRunStateRunning) - if next, ok := sm.ChatRunSnapshot(requestID, conversationID); ok { - return next - } - return snapshot -} func dispatchChatControl( sm *session.Manager, @@ -199,327 +178,6 @@ func TestChatRuntimeReadyRequiresFreshRuntimeHeartbeat(t *testing.T) { } } -func TestChatRunSeqContinuesWithinConversation(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1"); !ok || snapshot.LatestSeq != 3 { - t.Fatalf("first snapshot = %#v ok=%v, want latest seq 3", snapshot, ok) - } - - next, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-2") - if err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - if next.LatestSeq != 3 { - t.Fatalf("second run initial snapshot = %#v, want latest seq 3", next) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - if snapshot, ok := sm.ChatRunSnapshot("request-2", "conversation-1"); !ok || snapshot.LatestSeq != 4 { - t.Fatalf("second snapshot = %#v ok=%v, want latest seq 4", snapshot, ok) - } - - ch, _, cleanup, replaySnapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer cleanup() - if replaySnapshot.RequestID != "request-2" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", replaySnapshot) - } - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-ch: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - want := []string{ - "request-1:1:accepted", - "request-1:2:user_message", - "request-1:3:completed", - "request-2:4:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation replay = %#v, want %#v", got, want) - } - } -} - -func TestSubscribeChatRunConversationReplayAttachesLatestLiveRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if _, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-2"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - ch, done, cleanup, replaySnapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if replaySnapshot.RequestID != "request-2" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want live request-2 seq 4", replaySnapshot) - } - - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-ch: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-2", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"second"}`, - }, - }, - }) - select { - case event := <-ch: - if event.RequestID != "request-2" || event.Seq != 5 || event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("live event after replay = %#v, want request-2 token seq 5", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for live event after conversation replay, got %#v", got) - } -} - -type blockingAppendChatEventStore struct { - mu sync.Mutex - appendCalls int - appendEntered chan struct{} - releaseFirst chan struct{} -} - -func newBlockingAppendChatEventStore() *blockingAppendChatEventStore { - return &blockingAppendChatEventStore{ - appendEntered: make(chan struct{}), - releaseFirst: make(chan struct{}), - } -} - -func (s *blockingAppendChatEventStore) StartRun(input session.ChatRunStoreStart) (session.ChatRunSnapshot, bool, error) { - return session.ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: 1, - State: session.ChatRunStateQueued, - }, true, nil -} - -func (s *blockingAppendChatEventStore) AppendEvents(inputs []session.ChatRunEventAppend) error { - if len(inputs) == 0 { - return nil - } - s.mu.Lock() - s.appendCalls += 1 - call := s.appendCalls - s.mu.Unlock() - if call == 1 { - close(s.appendEntered) - <-s.releaseFirst - } - return nil -} - -func (s *blockingAppendChatEventStore) Replay(string, string, int64, int) (session.ChatRunSnapshot, []*session.ChatBroadcastEvent, bool, error) { - return session.ChatRunSnapshot{}, nil, false, nil -} - -func (s *blockingAppendChatEventStore) FailOpenRuns(string) error { - return nil -} - -func (s *blockingAppendChatEventStore) Close() error { - return nil -} - -func TestChatEventStoreAppendDoesNotHoldChatLock(t *testing.T) { - t.Parallel() - - store := newBlockingAppendChatEventStore() - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - - firstDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - close(firstDone) - }() - - select { - case <-store.appendEntered: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to block") - } - - secondDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "projection_updated", - "message": "second", - }) - close(secondDone) - }() - - select { - case <-secondDone: - case <-time.After(time.Second): - t.Fatal("second chat payload blocked behind event store append") - } - - snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1") - if !ok || snapshot.LatestSeq != 2 { - t.Fatalf("snapshot = %#v ok=%v, want latest seq 2 while first append is still blocked", snapshot, ok) - } - - close(store.releaseFirst) - select { - case <-firstDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to finish") - } -} - -func TestStartAcceptedChatCommandUsesInMemoryConversationSeqDuringPersistLag(t *testing.T) { - t.Parallel() - - store := newBlockingAppendChatEventStore() - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - - firstDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - close(firstDone) - }() - - select { - case <-store.appendEntered: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to block") - } - - next, created, acceptedSeq, err := sm.StartAcceptedChatCommandRun( - "request-2", - "conversation-1", - "client-2", - "", - nil, - ) - if err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun request-2 created=%v err=%v", created, err) - } - if acceptedSeq != 2 || next.LatestSeq != 2 { - t.Fatalf("second run snapshot = %#v acceptedSeq=%d, want seq 2", next, acceptedSeq) - } - - close(store.releaseFirst) - select { - case <-firstDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to finish") - } -} - -func TestClearSessionIfHeartbeatStaleFailsOpenChatRuns(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sess := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(sess) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - time.Sleep(time.Millisecond) - if !sm.ClearSessionIfHeartbeatStale(sess, time.Nanosecond) { - t.Fatalf("current stale session was not cleared") - } - select { - case event := <-ch: - if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) - } - if !strings.Contains(event.Event.GetData(), "Desktop agent disconnected") { - t.Fatalf("event data = %q", event.Event.GetData()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for heartbeat timeout chat error") - } -} func TestDispatchFromStaleSessionIsIgnored(t *testing.T) { t.Parallel() @@ -603,14 +261,14 @@ func TestSendToAgentUnblocksWhenSessionCloses(t *testing.T) { } } -func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { +func TestSendToAgentContextTimeoutKeepsSessionAlive(t *testing.T) { t.Parallel() sm := newTestSessionManager() sess := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(sess) - for i := 0; i < 64; i += 1 { + for i := 0; i < cap(sess.Outbound()); i += 1 { if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { t.Fatalf("prime outbound queue: %v", err) } @@ -623,726 +281,312 @@ func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("SendToAgentContext with full queue = %v, want context deadline exceeded", err) } - if status := sm.Status(); status.Online { - t.Fatalf("status online = true after SendToAgentContext timeout") - } -} - -func TestRemoveChatRunByConversationReleasesBufferedRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - startRunningChatCommandRun(t, sm, "request-1", "conversation-1") - - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun before remove: %v", err) - } - if snapshot.RequestID != "request-1" { - t.Fatalf("snapshot request id = %q, want request-1", snapshot.RequestID) + if status := sm.Status(); !status.Online { + t.Fatalf("status online = false after SendToAgentContext timeout; congestion must not kill the session") } - - sm.RemoveChatRunByConversation("conversation-1") - assertDoneClosed(t, done) - cleanup() select { - case event := <-ch: - t.Fatalf("unexpected replay event after remove: %#v", event) + case <-sess.Done(): + t.Fatalf("session closed after SendToAgentContext timeout") default: } - - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun after remove = %v, want ErrChatRunNotFound", err) - } } -func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { +func TestSendPingBypassesFullOutboundQueue(t *testing.T) { t.Parallel() sm := newTestSessionManager() - first, created, err := sm.StartPendingChatCommandRun( - "request-1", - "", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun first: %v", err) - } - if !created { - t.Fatalf("first run created = false, want true") - } - if first.RequestID != "request-1" { - t.Fatalf("first request id = %q, want request-1", first.RequestID) - } - if first.ClientRequestID != "client-submit-1" { - t.Fatalf("first client request id = %q, want client-submit-1", first.ClientRequestID) - } - - duplicate, created, err := sm.StartPendingChatCommandRun( - "request-2", - "", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun duplicate: %v", err) - } - if created { - t.Fatalf("duplicate run created = true, want false") - } - if duplicate.RequestID != "request-1" { - t.Fatalf("duplicate request id = %q, want original request-1", duplicate.RequestID) - } - - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("request-2", "", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun duplicate request = %v, want ErrChatRunNotFound", err) - } - - sm.RemoveChatRun("request-1") - restarted, created, err := sm.StartPendingChatCommandRun( - "request-3", - "", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun after remove: %v", err) - } - if !created { - t.Fatalf("restarted run created = false, want true") - } - if restarted.RequestID != "request-3" { - t.Fatalf("restarted request id = %q, want request-3", restarted.RequestID) - } -} - -func TestPendingChatRunBecomesActiveOnlyAfterStartedEvent(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - snapshot, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || snapshot.RequestID != "request-1" { - t.Fatalf("pending run = %#v created=%v", snapshot, created) - } - if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { - t.Fatalf("pending active chat runs = %#v, want empty", got) - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - dispatchChatControl(sm, "request-1", "conversation-1", "delivered", session.ChatRunStateDelivered) - if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { - t.Fatalf("accepted active chat runs = %#v, want empty", got) - } - if sm.FailStartingChatRun("request-1", "desktop did not accept") { - t.Fatalf("accepted pending run should not fail the accept watchdog") - } - - dispatchChatControl(sm, "request-1", "conversation-1", "started", session.ChatRunStateRunning) - - got := sm.ActiveChatRunConversationIDs() - want := []string{"conversation-1"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after started = %#v, want %#v", got, want) - } - select { - case event := <-ch: - if event.Control == nil || event.Control.GetType() != "delivered" { - t.Fatalf("first control event = %#v, want delivered", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for delivered control event") - } - select { - case event := <-ch: - if event.Control == nil || event.Control.GetType() != "started" { - t.Fatalf("second control event = %#v, want started", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for started control event") - } -} - -func TestFailStartingChatRunBroadcastsErrorAndClearsActiveSummary(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - if !sm.FailStartingChatRun("request-1", "desktop did not accept") { - t.Fatalf("FailStartingChatRun returned false") - } + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) - select { - case event := <-ch: - if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) - } - if !strings.Contains(event.Event.GetData(), "desktop did not accept") { - t.Fatalf("event data = %q", event.Event.GetData()) + for i := 0; i < cap(sess.Outbound()); i += 1 { + if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + t.Fatalf("prime outbound queue: %v", err) } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for starting run failure event") - } - if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { - t.Fatalf("active chat runs after failed start = %#v, want empty", got) } - if status := sm.Status(); status.Online { - t.Fatalf("status online = true after chat run failed before desktop accept") - } -} -func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun request-1: %v", err) + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-1"}); err != nil { + t.Fatalf("SendPing with full outbound queue: %v", err) } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - if sm.FailUnstartedChatRun("request-1", "desktop app did not start") { - t.Fatalf("unaccepted pending run should not fail the render-start watchdog") + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-2"}); err != nil { + t.Fatalf("SendPing replacing queued ping: %v", err) } - dispatchChatControl(sm, "request-1", "conversation-1", "delivered", session.ChatRunStateDelivered) - if !sm.FailUnstartedChatRun("request-1", "desktop app did not start") { - t.Fatalf("FailUnstartedChatRun returned false for accepted pending run") - } select { - case event := <-ch: - if event.Control == nil || event.Control.GetType() != "delivered" { - t.Fatalf("event = %#v, want delivered control", event) + case ping := <-sess.Pings(): + if ping.GetRequestId() != "ping-2" { + t.Fatalf("queued ping = %q, want latest ping-2", ping.GetRequestId()) } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for delivered control event") - } - select { - case event := <-ch: - if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event = %#v, want ERROR", event) - } - if !strings.Contains(event.Event.GetData(), "desktop app did not start") { - t.Fatalf("event data = %q", event.Event.GetData()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for unstarted run failure event") + default: + t.Fatalf("no ping queued on the dedicated lane") } - if _, _, err := sm.StartPendingChatCommandRun( - "request-2", - "conversation-2", - "client-submit-2", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun request-2: %v", err) - } - dispatchChatControl(sm, "request-2", "conversation-2", "started", session.ChatRunStateRunning) - if sm.FailUnstartedChatRun("request-2", "desktop app did not start") { - t.Fatalf("started run should not fail the render-start watchdog") + sess.Close() + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-3"}); !errors.Is(err, session.ErrAgentOffline) { + t.Fatalf("SendPing after close = %v, want ErrAgentOffline", err) } } -func TestTerminalChatRunStateIsImmutable(t *testing.T) { + +func TestChatQueueEventsReplayLatestSnapshotToNewSubscribers(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_ERROR, - ConversationId: "conversation-1", - Data: `{"message":"startup failed"}`, + RequestId: "queue-event-1", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: " conversation-1 ", + SnapshotJson: `{"conversationId":"conversation-1","revision":2,"items":[{"id":"queue-1"}]}`, + Revision: 2, }, }, }) sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - Type: "completed", - State: session.ChatRunStateCompleted, - RequestId: "request-1", + RequestId: "queue-event-stale", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", + SnapshotJson: `{"conversationId":"conversation-1","revision":1,"items":[]}`, + Revision: 1, }, }, }) sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, + RequestId: "queue-event-zero", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", - Data: `{"text":"late token"}`, + SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, + Revision: 0, }, }, }) - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.State != session.ChatRunStateFailed { - t.Fatalf("terminal state = %q, want %q", snapshot.State, session.ChatRunStateFailed) + cached, ok := sm.ChatQueueSnapshot("conversation-1") + if !ok || cached.GetRevision() != 2 || !strings.Contains(cached.GetSnapshotJson(), "queue-1") { + t.Fatalf("cached queue snapshot = %#v ok=%v, want revision 2 with queue-1", cached, ok) } + events, cleanup := sm.SubscribeChatQueueEvents() + defer cleanup() select { - case event := <-ch: - if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("replayed event = %#v, want ERROR", event) + case event := <-events: + if event.GetConversationId() != "conversation-1" || + event.GetRevision() != 2 || + !strings.Contains(event.GetSnapshotJson(), "queue-1") { + t.Fatalf("replayed queue snapshot = %#v, want latest revision 2", event) } case <-time.After(time.Second): - t.Fatalf("timed out waiting for replayed error event") - } - select { - case event := <-ch: - t.Fatalf("terminal completion control should be ignored after failure: %#v", event) - default: + t.Fatal("timed out waiting for replayed queue snapshot") } } -func TestDesktopBroadcastChatEventCreatesAttachableRun(t *testing.T) { +func TestChatQueueSnapshotAllowsNewSessionToResetRevision(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "conversation-live-conversation-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, + RequestId: "queue-event-1", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", - Data: `{"text":"hello"}`, + SnapshotJson: `{"conversationId":"conversation-1","revision":5,"items":[{"id":"queue-1"}]}`, + Revision: 5, }, }, }) - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "conversation-live-conversation-1" { - t.Fatalf("snapshot request id = %q, want conversation-live-conversation-1", snapshot.RequestID) - } - - select { - case event := <-ch: - if event.Seq != 1 { - t.Fatalf("event seq = %d, want 1", event.Seq) - } - if event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("event type = %v, want TOKEN", event.Event.GetType()) - } - if event.Event.GetConversationId() != "conversation-1" { - t.Fatalf("conversation id = %q, want conversation-1", event.Event.GetConversationId()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for replayed desktop chat event") - } -} - -func TestHistoryRunningCreatesAttachableConversationRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", + RequestId: "queue-event-reset", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, + SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, + Revision: 0, }, }, }) - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "conversation-live-conversation-1" || - summaries[0].Workdir != "/workspace" || - summaries[0].FirstSeq != 1 { - t.Fatalf("active summaries = %#v", summaries) - } - - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "conversation-live-conversation-1" || - snapshot.State != session.ChatRunStateRunning || - snapshot.Workdir != "/workspace" || - snapshot.Done { - t.Fatalf("snapshot = %#v", snapshot) - } - - select { - case event := <-ch: - t.Fatalf("unexpected replay before first token: %#v", event) - default: + cached, ok := sm.ChatQueueSnapshot("conversation-1") + if !ok || cached.GetRevision() != 0 || strings.Contains(cached.GetSnapshotJson(), "queue-1") { + t.Fatalf("cached queue snapshot after new session = %#v ok=%v, want empty revision 0", cached, ok) } +} +func dispatchChatToken(sm *session.Manager, requestID string, conversationID string, text string) { sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "conversation-live-conversation-1", + RequestId: requestID, Payload: &gatewayv1.AgentEnvelope_ChatEvent{ ChatEvent: &gatewayv1.ChatEvent{ Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"hello"}`, + ConversationId: conversationID, + Data: fmt.Sprintf(`{"text":%q}`, text), }, }, }) - - select { - case event := <-ch: - if event.Seq != 1 { - t.Fatalf("event seq = %d, want 1", event.Seq) - } - if event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("event type = %v, want TOKEN", event.Event.GetType()) - } - if event.Workdir != "/workspace" { - t.Fatalf("event workdir = %q, want /workspace", event.Workdir) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for token after history running") - } } -func TestHistoryRunningPromotesDesktopQueuedCommandRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "client-1", "/workspace", []map[string]any{{ - "type": "user_message", - "message": "queued prompt", - }}); err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun queued created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-queued", "conversation-1", "queued_in_gui", session.ChatRunStateDesktopQueued) - +func dispatchChatDone(sm *session.Manager, requestID string, conversationID string) { sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, + RequestId: requestID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: "{}", }, }, }) - - queuedSnapshot, ok := sm.ChatRunSnapshot("request-queued", "conversation-1") - if !ok || queuedSnapshot.State != session.ChatRunStateRunning || queuedSnapshot.Workdir != "/workspace" { - t.Fatalf("queued snapshot = %#v, ok=%v; want running queued request with workdir", queuedSnapshot, ok) - } - - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "request-queued" || - summaries[0].FirstSeq != 1 || - summaries[0].LatestSeq != 3 { - t.Fatalf("active summaries = %#v, want request-queued replay cursor", summaries) - } - - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("request-queued", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "request-queued" || snapshot.State != session.ChatRunStateRunning { - t.Fatalf("snapshot = %#v, want running request-queued", snapshot) - } - - got := make([]string, 0, 3) - for len(got) < 3 { - select { - case event := <-ch: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for promoted queued replay, got %#v", got) - } - } - want := []string{ - "request-queued:1:accepted", - "request-queued:2:user_message", - "request-queued:3:queued_in_gui", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("promoted queued replay = %#v, want %#v", got, want) - } - } } -func TestStartedRunKeepsConversationOwnerWhenPreviousRunEmitsLateEvent(t *testing.T) { +func TestConversationStreamSeqContinuesAcrossDispatchedRuns(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - startRunningChatCommandRun(t, sm, "request-old", "conversation-1") - if _, created, err := sm.StartPendingChatCommandRun("request-new", "conversation-1", "client-new"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun new created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-new", "conversation-1", "started", session.ChatRunStateRunning) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-old", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"late old token"}`, - }, - }, - }) + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") + dispatchChatToken(sm, "run-1", "conversation-1", "first") + dispatchChatDone(sm, "run-1", "conversation-1") + dispatchChatControl(sm, "run-2", "conversation-1", "started", "running") + dispatchChatToken(sm, "run-2", "conversation-1", "second") - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "request-new" { - t.Fatalf("active summaries = %#v, want request-new", summaries) - } + sub := sm.SubscribeConversationStream("conversation-1", 0, "") + defer sub.Cleanup() - _, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) + var lastSeq int64 + runFinished := 0 + for _, event := range sub.Events { + if event.Seq <= lastSeq { + t.Fatalf("seq regressed: %d after %d", event.Seq, lastSeq) + } + lastSeq = event.Seq + if event.Type == "run_finished" { + runFinished++ + } } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "request-new" { - t.Fatalf("conversation snapshot request id = %q, want request-new", snapshot.RequestID) + if runFinished != 1 { + t.Fatalf("run_finished events = %d, want 1", runFinished) + } + if sub.Activity == nil || sub.Activity.RunID != "run-2" { + t.Fatalf("activity = %#v, want run-2", sub.Activity) } } -func TestCompletedHistoryUpsertDoesNotPreemptTerminalChatEvent(t *testing.T) { +func TestDispatchedHistoryRunningIdleAreDropped(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - started := startRunningChatCommandRun(t, sm, "request-1", "conversation-1") - ch, done, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", started.LatestSeq) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } + historyEvents, cleanup := sm.SubscribeHistorySync() defer cleanup() - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, + for _, kind := range []string{"running", "idle"} { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "history-sync", + Payload: &gatewayv1.AgentEnvelope_HistorySync{ + HistorySync: &gatewayv1.HistorySyncEvent{ + Kind: kind, + ConversationId: "conversation-1", + }, }, - }, - }) + }) + } + + select { + case event := <-historyEvents: + t.Fatalf("agent running/idle history event should be dropped, got %#v", event) + case <-time.After(50 * time.Millisecond): + } + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("history running must not create activity, got %#v", activities) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", + RequestId: "history-sync", Payload: &gatewayv1.AgentEnvelope_HistorySync{ HistorySync: &gatewayv1.HistorySyncEvent{ Kind: "upsert", ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - }, + Conversation: &gatewayv1.ConversationSummary{Id: "conversation-1"}, }, }, }) - - assertDoneOpen(t, done) select { - case event := <-ch: - if event.Event.GetType() != gatewayv1.ChatEvent_DONE { - t.Fatalf("event type = %v, want DONE", event.Event.GetType()) + case event := <-historyEvents: + if event.GetKind() != "upsert" { + t.Fatalf("history event kind = %q, want upsert", event.GetKind()) } case <-time.After(time.Second): - t.Fatalf("timed out waiting for terminal chat event") - } - - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun after release = %v, want ErrChatRunNotFound", err) + t.Fatalf("upsert history event was not forwarded") } } -func TestActiveChatRunConversationIDsReturnsOpenRuns(t *testing.T) { +func TestAgentDisconnectPreservesActiveConversationActivity(t *testing.T) { t.Parallel() sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - startRunningChatCommandRun(t, sm, "request-b", "conversation-b") - startRunningChatCommandRun(t, sm, "request-a", "conversation-a") - startRunningChatCommandRun(t, sm, "request-empty", "") - startRunningChatCommandRun(t, sm, "request-a-duplicate", "conversation-a") + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-b", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-b", - Data: `{}`, - }, - }, - }) + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") + sm.ClearSession(sess) - got := sm.ActiveChatRunConversationIDs() - want := []string{"conversation-a"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat run conversation ids = %#v, want %#v", got, want) + activities := sm.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-1" { + t.Fatalf("activities after disconnect = %#v, want run-1 preserved", activities) } } -func TestClearSessionFailsOpenChatRuns(t *testing.T) { +func TestTerminalSnapshotFinishesRunAndStaleRunningIsIgnored(t *testing.T) { t.Parallel() sm := newTestSessionManager() - sess := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(sess) - first, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || first.RequestID != "request-1" { - t.Fatalf("first run = %#v created=%v", first, created) - } - - ch, done, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - sm.ClearSession(sess) - assertDoneClosed(t, sess.Done()) - assertDoneOpen(t, done) + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - select { - case event := <-ch: - if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) - } - if event.Event.GetConversationId() != "conversation-1" { - t.Fatalf("conversation id = %q, want conversation-1", event.Event.GetConversationId()) - } - if !strings.Contains(event.Event.GetData(), "Desktop agent disconnected") { - t.Fatalf("event data = %q, want disconnect message", event.Event.GetData()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for disconnect chat error") + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatRuntimeSnapshot{ + ChatRuntimeSnapshot: &gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", + ConversationId: "conversation-1", + State: "completed", + }, + }, + }) + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("terminal snapshot should clear activity, got %#v", activities) } - if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { - t.Fatalf("active chat runs after disconnect = %#v, want empty", got) + // A stale "running" snapshot after the terminal must not resurrect the run. + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatRuntimeSnapshot{ + ChatRuntimeSnapshot: &gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", + ConversationId: "conversation-1", + State: "running", + }, + }, + }) + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("stale running snapshot resurrected the run: %#v", activities) } - restarted, created, err := sm.StartPendingChatCommandRun( - "request-2", - "conversation-1", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun retry: %v", err) + sub := sm.SubscribeConversationStream("conversation-1", 0, "") + defer sub.Cleanup() + finished := 0 + for _, event := range sub.Events { + if event.Type == "run_finished" { + finished++ + } } - if !created || restarted.RequestID != "request-2" { - t.Fatalf("retry run = %#v created=%v, want new request-2", restarted, created) + if finished != 1 { + t.Fatalf("run_finished events = %d, want exactly 1", finished) } } - -func TestStaleClearSessionDoesNotFailReplacementChatRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - first := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(first) - second := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(second) - - startRunningChatCommandRun(t, sm, "request-current", "conversation-current") - sm.ClearSession(first) - - got := sm.ActiveChatRunConversationIDs() - want := []string{"conversation-current"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after stale clear = %#v, want %#v", got, want) - } - assertDoneOpen(t, second.Done()) -} diff --git a/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go new file mode 100644 index 000000000..1cddc8ba7 --- /dev/null +++ b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go @@ -0,0 +1,373 @@ +package tunnel_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/server" + "github.com/liveagent/agent-gateway/internal/session" +) + +// fakeAgent emulates the desktop agent's data-plane behavior in-process: it +// drains the session outbound queue and answers tunnel frames the way the +// Rust proxy does (HTTP echo, SSE stream, WS echo, PONG). +type fakeAgent struct { + sm *session.Manager + sess *session.AgentSession + done chan struct{} + once sync.Once +} + +func startFakeAgent(t *testing.T) (*session.Manager, *fakeAgent) { + t.Helper() + sm := session.NewManager() + sess := session.NewAgentSession(session.AuthSnapshot{AgentID: "fake-agent"}) + sm.SetSession(sess) + agent := &fakeAgent{sm: sm, sess: sess, done: make(chan struct{})} + go agent.run() + t.Cleanup(agent.stop) + return sm, agent +} + +func (a *fakeAgent) stop() { + a.once.Do(func() { close(a.done) }) +} + +func (a *fakeAgent) run() { + for { + select { + case <-a.done: + return + case outbound := <-a.sess.Outbound(): + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + outbound.Ack(nil) + frame := outbound.GatewayEnvelope.GetTunnelFrame() + if frame == nil { + continue + } + a.handleFrame(frame) + } + } +} + +func (a *fakeAgent) reply(frame *gatewayv1.TunnelFrame) { + a.sm.DispatchFromAgentForSession(a.sess, &gatewayv1.AgentEnvelope{ + RequestId: "fake-agent-frame", + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_TunnelFrame{TunnelFrame: frame}, + }) +} + +func (a *fakeAgent) handleFrame(frame *gatewayv1.TunnelFrame) { + streamID := frame.GetStreamId() + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PING: + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PONG, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START: + if strings.HasPrefix(frame.GetPath(), "/sse") { + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/event-stream; charset=utf-8"}, + }, + }) + for _, chunk := range []string{"event: tick\ndata: 1\n\n", "event: tick\ndata: 2\n\n"} { + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte(chunk), + }) + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + return + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/plain; charset=utf-8"}, + }, + }) + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte("hello " + frame.GetMethod() + " " + frame.GetPath()), + }) + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL: + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_OK, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: + if string(frame.GetBody()) == "close-me" { + // Emulate the local service closing the socket with its own code. + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: 4321, + WsCloseReason: "goodbye", + }) + return + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, + Body: append([]byte("echo:"), frame.GetBody()...), + WsMessageType: frame.GetWsMessageType(), + }) + } +} + +func startTunnelTestServer(t *testing.T, sm *session.Manager) *httptest.Server { + t.Helper() + handler := server.NewHTTPServer(&config.Config{ + Token: "dev-token", + RequestTimeout: 2 * time.Second, + }, sm) + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + return ts +} + +func applyOneTunnel(t *testing.T, sm *session.Manager) string { + t.Helper() + sm.ApplyDesiredState(&gatewayv1.TunnelDesiredState{ + Tunnels: []*gatewayv1.TunnelSpec{ + {Id: "tun-e2e", TargetUrl: "http://localhost:3999", Name: "e2e"}, + }, + }) + snapshot := sm.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("tunnels = %d, want 1", len(snapshot.GetTunnels())) + } + slug := snapshot.GetTunnels()[0].GetSlug() + if slug == "" { + t.Fatal("no slug allocated") + } + return slug +} + +func TestTunnelEndToEndHTTP(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/app/page?x=1") + if err != nil { + t.Fatalf("GET through tunnel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, body) + } + if got := string(body); got != "hello GET /app/page?x=1" { + t.Fatalf("body = %q", got) + } +} + +func TestTunnelEndToEndSSEStreams(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/sse") + if err != nil { + t.Fatalf("GET sse through tunnel: %v", err) + } + defer resp.Body.Close() + if contentType := resp.Header.Get("Content-Type"); !strings.Contains(contentType, "text/event-stream") { + t.Fatalf("content-type = %q", contentType) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "data: 1") || !strings.Contains(string(body), "data: 2") { + t.Fatalf("sse body = %q", body) + } +} + +func TestTunnelEndToEndWebSocketEchoAndClose(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/t/" + slug + "/socket" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial tunnel websocket: %v", err) + } + defer conn.Close() + + if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil { + t.Fatalf("write: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + messageType, body, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read echo: %v", err) + } + if messageType != websocket.TextMessage || string(body) != "echo:ping" { + t.Fatalf("echo = (%d, %q)", messageType, body) + } + + // Upstream-initiated close: the local service's close code/reason must + // reach the visitor verbatim through the frame relay. + if err := conn.WriteMessage(websocket.TextMessage, []byte("close-me")); err != nil { + t.Fatalf("write close-me: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err = conn.ReadMessage() + closeErr, ok := err.(*websocket.CloseError) + if !ok { + t.Fatalf("expected close error, got %v", err) + } + if closeErr.Code != 4321 || closeErr.Text != "goodbye" { + t.Fatalf("close = (%d, %q), want (4321, goodbye)", closeErr.Code, closeErr.Text) + } +} + +// TestTunnelTrafficWhileControlPlaneBusy is the deadlock regression: the old +// design executed probes synchronously on the agent read loop, so any control +// activity starved the data plane. The new design must serve traffic while +// desired-state applies and relay probes run concurrently. +func TestTunnelTrafficWhileControlPlaneBusy(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + stop := make(chan struct{}) + var controlWG sync.WaitGroup + controlWG.Add(1) + go func() { + defer controlWG.Done() + for { + select { + case <-stop: + return + default: + sm.ApplyDesiredState(&gatewayv1.TunnelDesiredState{ + Tunnels: []*gatewayv1.TunnelSpec{ + {Id: "tun-e2e", TargetUrl: "http://localhost:3999", Name: "e2e", SlugHint: slug}, + }, + }) + } + } + }() + + deadline := time.Now().Add(2 * time.Second) + requests := 0 + for time.Now().Before(deadline) { + client := http.Client{Timeout: time.Second} + resp, err := client.Get(ts.URL + "/t/" + slug + "/") + if err != nil { + close(stop) + controlWG.Wait() + t.Fatalf("request %d during control churn: %v", requests, err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + close(stop) + controlWG.Wait() + t.Fatalf("request %d status = %d", requests, resp.StatusCode) + } + requests += 1 + } + close(stop) + controlWG.Wait() + if requests < 10 { + t.Fatalf("only %d requests completed during control churn", requests) + } +} + +func TestTunnelHTMLRewriteInjectsShimAndDropsContentLength(t *testing.T) { + sm := session.NewManager() + sess := session.NewAgentSession(session.AuthSnapshot{AgentID: "fake-agent"}) + sm.SetSession(sess) + agent := &fakeAgent{sm: sm, sess: sess, done: make(chan struct{})} + t.Cleanup(agent.stop) + + html := "
x" + go func() { + for { + select { + case <-agent.done: + return + case outbound := <-sess.Outbound(): + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + outbound.Ack(nil) + frame := outbound.GatewayEnvelope.GetTunnelFrame() + if frame == nil || + frame.GetKind() != gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START { + continue + } + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/html; charset=utf-8"}, + {Name: "Content-Length", Value: "999"}, + {Name: "Content-Security-Policy", Value: "script-src 'self'"}, + }, + }) + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte(html), + }) + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + } + } + }() + + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/") + if err != nil { + t.Fatalf("GET html through tunnel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.Header.Get("Content-Length") == "999" { + t.Fatal("stale Content-Length must be dropped for rewritten responses") + } + if !strings.Contains(string(body), "data-liveagent-tunnel-shim") { + t.Fatalf("shim not injected: %q", body) + } + if !strings.Contains(string(body), "/t/"+slug+"/about") { + t.Fatalf("href not rewritten: %q", body) + } + if policy := resp.Header.Get("Content-Security-Policy"); !strings.Contains(policy, "'sha256-") { + t.Fatalf("CSP not hash-amended: %q", policy) + } +} diff --git a/crates/agent-gateway/test/websocket/chat_test.go b/crates/agent-gateway/test/websocket/chat_test.go new file mode 100644 index 000000000..5be48aa2c --- /dev/null +++ b/crates/agent-gateway/test/websocket/chat_test.go @@ -0,0 +1,347 @@ +package websocket_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/server" + "github.com/liveagent/agent-gateway/internal/session" +) + +func newChatWebSocketTest(t *testing.T) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { + t.Helper() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + authWebSocket(t, conn, "ws-token") + return sm, agentSession, conn, cleanup +} + +func decodePayload(t *testing.T, env wsEnvelope) map[string]any { + t.Helper() + payload := map[string]any{} + if len(env.Payload) > 0 { + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode payload for %s: %v", env.Type, err) + } + } + return payload +} + +// receiveEventOfType skips unrelated frames (pings, other event types) until +// an envelope of the wanted type arrives. +func receiveEventOfType(t *testing.T, conn *websocket.Conn, eventType string) map[string]any { + t.Helper() + for attempt := 0; attempt < 16; attempt++ { + env := receiveEnvelope(t, conn) + if env.Type == eventType { + return decodePayload(t, env) + } + } + t.Fatalf("timed out waiting for %s event", eventType) + return nil +} + +func dispatchStarted(sm *session.Manager, runID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "started", + State: "running", + }, + }, + }) +} + +func dispatchToken(sm *session.Manager, runID string, conversationID string, text string) { + data, _ := json.Marshal(map[string]any{"text": text}) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: string(data), + }, + }, + }) +} + +func dispatchDone(sm *session.Manager, runID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: "{}", + }, + }, + }) +} + +// The subscription is conversation-scoped and persists across run boundaries: +// a queued prompt auto-send (new run started by the desktop app) streams into +// the same subscription with no re-subscribe handshake. +func TestChatSubscribePersistsAcrossRunHandoff(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) + if resp["conversation_id"] != "conversation-1" || resp["stream_epoch"] == "" { + t.Fatalf("subscribe response = %#v", resp) + } + if resp["activity"] != nil { + t.Fatalf("idle conversation must report nil activity, got %#v", resp["activity"]) + } + + dispatchStarted(sm, "run-1", "conversation-1") + started := receiveEventOfType(t, conn, "chat.event") + if started["type"] != "run_started" || started["run_id"] != "run-1" { + t.Fatalf("first push = %#v, want run_started run-1", started) + } + + dispatchToken(sm, "run-1", "conversation-1", "hello") + token := receiveEventOfType(t, conn, "chat.event") + if token["type"] != "token" || token["run_id"] != "run-1" || token["text"] != "hello" { + t.Fatalf("token push = %#v", token) + } + + dispatchDone(sm, "run-1", "conversation-1") + finished := receiveEventOfType(t, conn, "chat.event") + if finished["type"] != "run_finished" || finished["status"] != "completed" { + t.Fatalf("finish push = %#v", finished) + } + + // Queue auto-send: a new run flows into the same subscription. + dispatchStarted(sm, "run-2", "conversation-1") + second := receiveEventOfType(t, conn, "chat.event") + if second["type"] != "run_started" || second["run_id"] != "run-2" { + t.Fatalf("handoff push = %#v, want run_started run-2", second) + } + dispatchToken(sm, "run-2", "conversation-1", "again") + tail := receiveEventOfType(t, conn, "chat.event") + if tail["run_id"] != "run-2" || tail["text"] != "again" { + t.Fatalf("handoff token = %#v", tail) + } +} + +func TestChatActivityBroadcastCarriesRunIDs(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + dispatchStarted(sm, "run-1", "conversation-1") + running := receiveEventOfType(t, conn, "chat.activity") + if running["running"] != true || running["run_id"] != "run-1" || running["conversation_id"] != "conversation-1" { + t.Fatalf("running activity = %#v", running) + } + + dispatchDone(sm, "run-1", "conversation-1") + idle := receiveEventOfType(t, conn, "chat.activity") + if idle["running"] != false || idle["conversation_id"] != "conversation-1" { + t.Fatalf("idle activity = %#v", idle) + } +} + +func TestChatCommandSubmitSeedsUserMessageAndDeliversEnvelope(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + receiveEnvelopeWithID(t, conn, "sub-1") + + sendEnvelope(t, conn, "cmd-1", "chat.command", map[string]any{ + "type": "chat.submit", + "payload": map[string]any{ + "message": "hello agent", + "conversation_id": "conversation-1", + "client_request_id": "client-1", + }, + }) + + resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "cmd-1")) + runID, _ := resp["run_id"].(string) + if runID == "" || resp["conversation_id"] != "conversation-1" { + t.Fatalf("command response = %#v", resp) + } + if seq, ok := resp["accepted_seq"].(float64); !ok || seq <= 0 { + t.Fatalf("accepted_seq = %#v, want > 0", resp["accepted_seq"]) + } + + seeded := receiveEventOfType(t, conn, "chat.event") + if seeded["type"] != "user_message" || + seeded["message"] != "hello agent" || + seeded["client_request_id"] != "client-1" || + seeded["run_id"] != runID { + t.Fatalf("seeded user_message = %#v", seeded) + } + + outbound := readOutboundEnvelope(t, agentSession) + command := outbound.GetChatCommand() + if command == nil || command.GetType() != "chat.submit" { + t.Fatalf("outbound payload = %#v, want chat.submit command", outbound.GetPayload()) + } + if command.GetRequest().GetMessage() != "hello agent" || + command.GetRequest().GetClientRequestId() != "client-1" { + t.Fatalf("chat command request = %#v", command.GetRequest()) + } + + // The agent's user_message echo is swallowed: the next stream event after + // run start must not duplicate the seeded message. + dispatchStarted(sm, runID, "conversation-1") + startedPush := receiveEventOfType(t, conn, "chat.event") + if startedPush["type"] != "run_started" { + t.Fatalf("post-start push = %#v", startedPush) + } + echoData, _ := json.Marshal(map[string]any{"message": "hello agent"}) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conversation-1", + Data: string(echoData), + }, + }, + }) + dispatchToken(sm, runID, "conversation-1", "reply") + next := receiveEventOfType(t, conn, "chat.event") + if next["type"] != "token" || next["text"] != "reply" { + t.Fatalf("expected token after swallowed echo, got %#v", next) + } +} + +func TestChatCancelKeepsRunAliveUntilAgentConfirms(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + receiveEnvelopeWithID(t, conn, "sub-1") + + dispatchStarted(sm, "run-1", "conversation-1") + receiveEventOfType(t, conn, "chat.event") + + sendEnvelope(t, conn, "cancel-1", "chat.command", map[string]any{ + "type": "chat.cancel", + "payload": map[string]any{"conversation_id": "conversation-1"}, + }) + + // The gateway blocks the response on agent delivery; ack it first. + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatCommand().GetType() != "chat.cancel" { + t.Fatalf("outbound cancel = %#v", outbound.GetPayload()) + } + + // The run is NOT terminalized by the gateway: activity flips to + // cancelling and the agent's terminal signal wins. The response and the + // activity push race on the outbox, so collect both order-independently. + var cancelling map[string]any + var resp map[string]any + for attempt := 0; attempt < 16 && (cancelling == nil || resp == nil); attempt++ { + env := receiveEnvelope(t, conn) + switch { + case env.Type == "chat.activity": + payload := decodePayload(t, env) + if payload["state"] == "cancelling" { + cancelling = payload + } + case env.ID == "cancel-1": + resp = decodePayload(t, env) + } + } + if cancelling == nil || cancelling["running"] != true { + t.Fatalf("cancelling activity = %#v", cancelling) + } + if resp == nil || resp["ok"] != true || resp["run_id"] != "run-1" { + t.Fatalf("cancel response = %#v", resp) + } + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conversation-1", + Type: "cancelled", + State: "cancelled", + }, + }, + }) + finished := receiveEventOfType(t, conn, "chat.event") + if finished["type"] != "run_finished" || finished["status"] != "cancelled" { + t.Fatalf("cancel finish = %#v", finished) + } +} + +func TestChatSubscribeResumesWithAfterSeq(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + dispatchStarted(sm, "run-1", "conversation-1") + dispatchToken(sm, "run-1", "conversation-1", "one") + dispatchToken(sm, "run-1", "conversation-1", "two") + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + first := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) + events, _ := first["events"].([]any) + if len(events) != 3 { + t.Fatalf("full replay = %d events, want 3", len(events)) + } + epoch, _ := first["stream_epoch"].(string) + latest, _ := first["latest_seq"].(float64) + + sendEnvelope(t, conn, "sub-2", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + "after_seq": latest - 1, + "stream_epoch": epoch, + }) + resumed := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-2")) + resumedEvents, _ := resumed["events"].([]any) + if resumed["reset"] != false || len(resumedEvents) != 1 { + t.Fatalf("resume = reset:%v events:%d, want reset:false events:1", resumed["reset"], len(resumedEvents)) + } + + sendEnvelope(t, conn, "sub-3", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + "after_seq": latest, + "stream_epoch": "stale-epoch", + }) + mismatched := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-3")) + if mismatched["reset"] != true { + t.Fatalf("epoch mismatch must reset, got %#v", mismatched) + } +} diff --git a/crates/agent-gateway/test/websocket/terminal_test.go b/crates/agent-gateway/test/websocket/terminal_test.go index ab98f6692..ea49e8d64 100644 --- a/crates/agent-gateway/test/websocket/terminal_test.go +++ b/crates/agent-gateway/test/websocket/terminal_test.go @@ -39,17 +39,23 @@ func receiveNoTerminalEnvelope(t *testing.T, _ *websocket.Conn) { func assertNoTerminalEnvelopeAtEnd(t *testing.T, conn *websocket.Conn) { t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { - t.Fatalf("set websocket short deadline: %v", err) - } - var env wsEnvelope - err := conn.ReadJSON(&env) - if err == nil { - t.Fatalf("unexpected websocket envelope = %#v", env) - } - var netErr net.Error - if !errors.As(err, &netErr) || !netErr.Timeout() { - t.Fatalf("receive websocket envelope returned %v, want timeout", err) + for { + if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { + t.Fatalf("set websocket short deadline: %v", err) + } + var env wsEnvelope + err := conn.ReadJSON(&env) + if err == nil { + if env.Type == "tunnel.state" { + continue + } + t.Fatalf("unexpected websocket envelope = %#v", env) + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("receive websocket envelope returned %v, want timeout", err) + } + return } } diff --git a/crates/agent-gateway/test/websocket/websocket_helpers_test.go b/crates/agent-gateway/test/websocket/websocket_helpers_test.go index bf797ddb0..9592f0cf3 100644 --- a/crates/agent-gateway/test/websocket/websocket_helpers_test.go +++ b/crates/agent-gateway/test/websocket/websocket_helpers_test.go @@ -56,14 +56,21 @@ func sendEnvelope(t *testing.T, conn *websocket.Conn, id string, typ string, pay func receiveEnvelope(t *testing.T, conn *websocket.Conn) wsEnvelope { t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - t.Fatalf("receive websocket envelope: %v", err) + for { + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set websocket read deadline: %v", err) + } + var env wsEnvelope + if err := conn.ReadJSON(&env); err != nil { + t.Fatalf("receive websocket envelope: %v", err) + } + // tunnel.state is broadcast on auth and on unrelated state changes; + // tests assert on the envelopes they explicitly provoke. + if env.Type == "tunnel.state" { + continue + } + return env } - return env } func receiveEnvelopeWithID(t *testing.T, conn *websocket.Conn, id string) wsEnvelope { diff --git a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs new file mode 100644 index 000000000..7288710a7 --- /dev/null +++ b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { ChatCommandPipeline } = loader.loadModule("src/lib/chat/stream/chatCommandPipeline.ts"); +const { createTranscriptStore } = loader.loadModule("src/lib/chat/transcript/transcriptStore.ts"); + +function createHarness() { + const stores = new Map(); + const outcomes = { bound: [], queued: [], failed: [] }; + const pipeline = new ChatCommandPipeline({ + getTranscriptStore(conversationId) { + let store = stores.get(conversationId); + if (!store) { + store = createTranscriptStore(); + stores.set(conversationId, store); + } + return store; + }, + onBound(update, pending) { + outcomes.bound.push({ update, pending }); + }, + onQueuedInGui(update, pending) { + outcomes.queued.push({ update, pending }); + }, + onFailed(pending, errorCode, message) { + outcomes.failed.push({ pending, errorCode, message }); + }, + }); + return { pipeline, stores, outcomes }; +} + +function rowText(row) { + if (row.kind === "assistant") { + return row.rounds + .map((round) => + round.blocks.flatMap((block) => (block.kind === "text" ? [block.text] : [])).join(""), + ) + .join("\n"); + } + return row.text ?? ""; +} + +// Live-flow texts (the region the old snapshot exposed as `tail`). +function tailTexts(store) { + store.flush(); + return store.getSnapshot().liveRows.map((row) => rowText(row)); +} + +test("submit inserts the optimistic bubble and resolves the accepted run", async () => { + const { pipeline, stores } = createHarness(); + const outcome = await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }), + }); + + assert.equal(outcome.kind, "accepted"); + assert.equal(outcome.accepted.runId, "run-1"); + assert.deepEqual(tailTexts(stores.get("conv-1")), ["hello"]); + assert.equal(pipeline.hasPending("conv-1"), true); + + // The stream's run signal settles the pending spinner. + pipeline.handleRunSignal("conv-1", "run-1"); + assert.equal(pipeline.hasPending("conv-1"), false); +}); + +test("submit failure removes the bubble and surfaces an error entry", async () => { + const { pipeline, stores, outcomes } = createHarness(); + const outcome = await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => { + throw new Error("agent offline"); + }, + }); + + assert.equal(outcome.kind, "failed"); + assert.equal(pipeline.hasPending("conv-1"), false); + const texts = tailTexts(stores.get("conv-1")); + assert.equal(texts.some((text) => text === "hello"), false, "optimistic bubble removed"); + assert.equal(texts.some((text) => /agent offline/.test(text)), true); + assert.equal(outcomes.failed.length, 1); +}); + +test("bound update re-keys a draft conversation", async () => { + const { pipeline, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "draft-1", + clientRequestId: "client-1", + message: "first message", + submit: async () => ({ runId: "run-1", conversationId: "", acceptedSeq: 0 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-real", + phase: "bound", + errorCode: null, + message: null, + }); + + assert.equal(outcomes.bound.length, 1); + assert.equal(outcomes.bound[0].pending.conversationId, "conv-real"); + assert.equal(pipeline.hasPending("draft-1"), false); + assert.equal(pipeline.hasPending("conv-real"), true); +}); + +test("queued_in_gui clears pending and removes the optimistic bubble", async () => { + const { pipeline, stores, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "park me", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 1 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "queued_in_gui", + errorCode: null, + message: null, + }); + + assert.equal(pipeline.hasPending("conv-1"), false); + assert.equal(outcomes.queued.length, 1); + assert.deepEqual(tailTexts(stores.get("conv-1")), [], "bubble removed; queue panel owns it"); +}); + +test("failed update surfaces the gateway error", async () => { + const { pipeline, stores, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "doomed", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 1 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "failed", + errorCode: "startup_timeout", + message: "did not start", + }); + + assert.equal(pipeline.hasPending("conv-1"), false); + assert.equal(outcomes.failed.length, 1); + assert.equal(outcomes.failed[0].errorCode, "startup_timeout"); + const texts = tailTexts(stores.get("conv-1")); + assert.equal(texts.some((text) => /did not start/.test(text)), true); +}); + +test("run signals settle only on strict identity (runId or own clientRequestId)", async () => { + const { pipeline } = createHarness(); + let releaseAccept; + const acceptGate = new Promise((resolve) => { + releaseAccept = resolve; + }); + const submitPromise = pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => { + await acceptGate; + return { runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }; + }, + }); + + // Accept response still in flight (pending.runId === null): a foreign run + // signal without our client_request_id must NOT settle the pending — + // otherwise a GUI queue auto-send would disarm the startup watchdog. + pipeline.handleRunSignal("conv-1", "run-foreign"); + assert.equal(pipeline.hasPending("conv-1"), true, "foreign signal ignored"); + pipeline.handleRunSignal("conv-1", "run-foreign", "client-other"); + assert.equal(pipeline.hasPending("conv-1"), true, "foreign clientRequestId ignored"); + + // Our own run signal, matched by client_request_id, settles before the + // accept response lands. + pipeline.handleRunSignal("conv-1", "run-1", "client-1"); + assert.equal(pipeline.hasPending("conv-1"), false, "own clientRequestId settles"); + + releaseAccept(); + const outcome = await submitPromise; + assert.equal(outcome.kind, "accepted"); + // The late accept response must not resurrect a byRunId registration for + // the already-settled pending: a later command_update for that run id is a + // no-op instead of firing hooks against a dead pending. + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-other", + phase: "bound", + errorCode: null, + message: null, + }); + assert.equal(pipeline.hasPending("conv-other"), false); +}); + +test("run signals with a known runId settle regardless of clientRequestId", async () => { + const { pipeline } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }), + }); + assert.equal(pipeline.hasPending("conv-1"), true); + + // Foreign run id: ignored even though the conversation matches. + pipeline.handleRunSignal("conv-1", "run-9"); + assert.equal(pipeline.hasPending("conv-1"), true); + + // Matching run id (e.g. an activity event while the conversation is not + // displayed) settles without any clientRequestId. + pipeline.handleRunSignal("conv-1", "run-1"); + assert.equal(pipeline.hasPending("conv-1"), false); +}); + + +test("optimistic:false suppresses the transcript echo for queue-destined sends", async () => { + const { pipeline, stores } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "park me quietly", + optimistic: false, + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 0 }), + }); + // No store is touched at submit time — the transcript never sees the prompt. + const storeAfterSubmit = stores.get("conv-1"); + if (storeAfterSubmit) { + assert.deepEqual(tailTexts(storeAfterSubmit), [], "no bubble flash"); + } + assert.equal(pipeline.hasPending("conv-1"), true, "watchdog still armed"); + + // queued_in_gui settles it without ever having shown a bubble. + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "queued_in_gui", + errorCode: null, + message: null, + }); + assert.equal(pipeline.hasPending("conv-1"), false); + assert.deepEqual(tailTexts(stores.get("conv-1")), []); +}); diff --git a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs b/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs deleted file mode 100644 index 6e8de7ee5..000000000 --- a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; - -test("chat stream recovery detects released attach streams", () => { - const loader = createWebModuleLoader(); - const { - isChatStreamNotAvailableEvent, - isChatStreamNotAvailableMessage, - resolveChatStreamUnavailableRecoveryAction, - shouldHydrateRestoredConversationSnapshot, - } = loader.loadModule("src/lib/chatStreamRecovery.ts"); - - assert.equal(isChatStreamNotAvailableMessage("chat stream not available"), true); - assert.equal( - isChatStreamNotAvailableMessage(new Error("Error: chat stream not available")), - true, - ); - assert.equal(isChatStreamNotAvailableMessage("chat request failed"), false); - - assert.equal( - isChatStreamNotAvailableEvent({ - type: "error", - message: "chat stream not available", - conversation_id: "conversation-1", - }), - true, - ); - assert.equal( - isChatStreamNotAvailableEvent({ - type: "done", - conversation_id: "conversation-1", - }), - false, - ); - assert.equal( - resolveChatStreamUnavailableRecoveryAction("conversation-1"), - "refresh-history-snapshot", - ); - assert.equal( - resolveChatStreamUnavailableRecoveryAction("__local_draft__:conversation-1"), - "reload-history", - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - liveEntries: [{ id: "live-assistant", kind: "assistant", text: "partial", round: 1 }], - historyEntries: [ - { id: "history-user", kind: "user", text: "hello", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "partial and final", round: 1 }, - ], - }), - true, - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - historyEntries: [{ id: "history-user", kind: "user", text: "hello", attachments: [] }], - }), - false, - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - liveEntries: [ - { id: "live-assistant", kind: "assistant", text: "partial text that is newer", round: 1 }, - ], - historyEntries: [ - { id: "history-user", kind: "user", text: "hello", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "partial", round: 1 }, - ], - }), - false, - ); -}); diff --git a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs index 167f21121..b1498ab38 100644 --- a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs +++ b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs @@ -2,130 +2,49 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; +// The chat turn queue itself lives in the desktop GUI (the gateway relays +// snapshots); the web module keeps only the composer-side content check. const loader = createWebModuleLoader(); -const queue = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); +const { queuedChatTurnHasContent } = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); -function draft(text, segments = [{ type: "text", text }]) { +function draft(overrides = {}) { return { - segments, - text, - textWithoutLargePastes: text, + isEmpty: false, + text: "hello", + textWithoutLargePastes: "hello", largePastes: [], - skillMentions: [], - commitMentions: [], - gitFileMentions: [], - isEmpty: text.trim() === "", + segments: [{ type: "text", text: "hello" }], + ...overrides, }; } -function turn(id, conversationId, text) { - return queue.createQueuedChatTurn({ - id, - conversationId, - draft: draft(text), - uploadedFiles: [], - workdir: "/workspace", - runtimeControls: { - thinkingEnabled: false, - reasoning: "off", - nativeWebSearchEnabled: false, - }, - createdAt: 1, - }); -} - -test("gateway web queued chat turns append, promote, remove, and take the next turn", () => { - const first = turn("a1", "conversation-a", "first"); - const second = turn("a2", "conversation-a", "second"); - - const appended = queue.appendQueuedChatTurn(queue.appendQueuedChatTurn([], first), second); - assert.deepEqual( - appended.map((item) => item.id), - ["a1", "a2"], - ); - - const promoted = queue.promoteQueuedChatTurn(appended, "a2"); - assert.deepEqual( - promoted.map((item) => item.id), - ["a2", "a1"], - ); - - const taken = queue.takeNextQueuedChatTurn(promoted, "conversation-a"); - assert.equal(taken.item.id, "a2"); - assert.deepEqual( - taken.queue.map((item) => item.id), - ["a1"], - ); - - assert.deepEqual(queue.removeQueuedChatTurn(taken.queue, "a1"), []); +test("queuedChatTurnHasContent accepts drafts with text", () => { + assert.equal(queuedChatTurnHasContent(draft(), []), true); }); -test("gateway web queued chat turn movement stays scoped to the same conversation", () => { - const mixed = [ - turn("a1", "conversation-a", "a one"), - turn("b1", "conversation-b", "b one"), - turn("a2", "conversation-a", "a two"), +test("queuedChatTurnHasContent accepts empty drafts with uploads", () => { + const uploads = [ + { + relativePath: "notes.md", + absolutePath: "/workspace/notes.md", + fileName: "notes.md", + kind: "text", + sizeBytes: 12, + }, ]; - - const moved = queue.moveQueuedChatTurn(mixed, "a2", "up"); - assert.deepEqual( - moved.map((item) => item.id), - ["a2", "b1", "a1"], - ); + assert.equal(queuedChatTurnHasContent(draft({ isEmpty: true, text: "" }), uploads), true); }); -test("gateway web edited queued chat turns return to their original priority slot", () => { - const first = turn("a1", "conversation-a", "first"); - const third = turn("a3", "conversation-a", "third"); - const editedSecond = turn("a2", "conversation-a", "edited second"); - - const reinserted = queue.insertQueuedChatTurnAtSlot([first, third], editedSecond, { - conversationId: "conversation-a", - previousId: "a1", - nextId: "a3", - index: 1, - }); - - assert.deepEqual( - reinserted.map((item) => item.id), - ["a1", "a2", "a3"], - ); - assert.equal(reinserted[1].draft.text, "edited second"); +test("queuedChatTurnHasContent rejects missing or empty drafts", () => { + assert.equal(queuedChatTurnHasContent(null, []), false); + assert.equal(queuedChatTurnHasContent(undefined, []), false); + assert.equal(queuedChatTurnHasContent(draft({ isEmpty: true, text: " " }), []), false); }); -test("gateway web queued chat turn preview keeps structured draft hints compact", () => { - const richDraft = draft("hello long paste", [ - { type: "text", text: "hello " }, - { - type: "largePaste", - paste: { - id: "paste-1", - label: "pasted.txt", - text: "large paste body", - charCount: 16, - lineCount: 1, - preview: "large paste body", - }, - }, - { - type: "fileMention", - reference: { - path: "src/notes.txt", - kind: "file", - }, - }, - { - type: "skillMention", - skill: { - name: "reviewer", - description: "", - skillFile: "SKILL.md", - baseDir: "/skills/reviewer", - }, - }, - ]); - - assert.equal(queue.buildQueuedChatTurnPreview(richDraft), "hello pasted.txtnotes.txt$reviewer"); - assert.equal(queue.queuedChatTurnHasContent(richDraft, []), true); - assert.equal(queue.queuedChatTurnHasContent(draft(""), [{ fileName: "a.txt" }]), true); +test("queuedChatTurnHasContent treats structured-only drafts as content", () => { + assert.equal( + queuedChatTurnHasContent(draft({ isEmpty: false, text: "" }), []), + true, + "non-empty draft flag wins even without plain text", + ); }); diff --git a/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs new file mode 100644 index 000000000..4c7d516ff --- /dev/null +++ b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { ConversationStreamClient } = loader.loadModule( + "src/lib/chat/stream/conversationStreamClient.ts", +); + +function createTransport() { + const calls = []; + let responder = () => ({}); + return { + calls, + setResponder(fn) { + responder = fn; + }, + request(type, payload) { + calls.push({ type, payload }); + return Promise.resolve(responder(type, payload)); + }, + }; +} + +function subscribeResponse(overrides = {}) { + return { + conversation_id: "conv-1", + stream_epoch: "epoch-1", + latest_seq: 0, + reset: false, + activity: null, + snapshot: null, + events: [], + ...overrides, + }; +} + +function collectHandlers() { + const seen = { syncs: [], events: [] }; + return { + seen, + handlers: { + onSync(result) { + seen.syncs.push(result); + }, + onEvent(event) { + seen.events.push(event); + }, + }, + }; +} + +async function flushMicrotasks() { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +test("subscribes with resume cursor and re-subscribes after reconnect", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 4 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + assert.equal(transport.calls.length, 1); + assert.equal(transport.calls[0].type, "chat.subscribe"); + assert.equal(transport.calls[0].payload.after_seq, 0); + assert.equal(seen.syncs.length, 1); + + // Live events advance the cursor. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 5, text: "a" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 6, text: "b" }); + assert.equal(seen.events.length, 2); + + // Disconnect + reconnect: the registration survives and resumes from seq 6 + // with the stream epoch. + client.handleDisconnected(); + transport.setResponder(() => subscribeResponse({ latest_seq: 8, events: [ + { type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 7, text: "c" }, + { type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 8, text: "d" }, + ] })); + client.handleConnected(); + await flushMicrotasks(); + + assert.equal(transport.calls.length, 2); + assert.equal(transport.calls[1].payload.after_seq, 6); + assert.equal(transport.calls[1].payload.stream_epoch, "epoch-1"); + assert.equal(seen.syncs.length, 2); + assert.equal(seen.syncs[1].events.length, 2); +}); + +test("duplicate and stale seqs are dropped; gaps trigger a resync", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 2 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "dup" }); + assert.equal(seen.events.length, 0, "stale seq dropped"); + + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 3, text: "ok" }); + assert.equal(seen.events.length, 1); + + transport.setResponder(() => subscribeResponse({ latest_seq: 9, events: [] })); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 9, text: "gap" }); + await flushMicrotasks(); + assert.equal(seen.events.length, 1, "gapped event not delivered directly"); + assert.equal( + transport.calls.filter((call) => call.type === "chat.subscribe").length, + 2, + "gap triggered a resync", + ); +}); + +test("events racing ahead of the subscribe response are buffered, then drained", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + transport.setResponder(() => gate.then(() => subscribeResponse({ latest_seq: 1 }))); + + client.subscribe("conv-1", handlers); + client.handleConnected(); + + // Pushes arrive while the subscribe response is still in flight. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "early" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 3, text: "birds" }); + assert.equal(seen.events.length, 0); + + release(); + await flushMicrotasks(); + assert.equal(seen.syncs.length, 1); + assert.deepEqual( + seen.events.map((event) => event.text), + ["early", "birds"], + ); +}); + +test("seq-less events (snapshot pushes) pass through without cursor changes", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 5 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleChatEvent({ type: "snapshot", conversation_id: "conv-1", run_id: "run-1", entries_json: "[]" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 6, text: "next" }); + assert.deepEqual( + seen.events.map((event) => event.type), + ["snapshot", "token"], + ); +}); + +test("subscription_reset resumes from the cursor; cleanup unsubscribes", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 3 })); + const cleanup = client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleSubscriptionReset({ conversation_id: "conv-1" }); + await flushMicrotasks(); + const subscribes = transport.calls.filter((call) => call.type === "chat.subscribe"); + assert.equal(subscribes.length, 2); + assert.equal(subscribes[1].payload.after_seq, 3); + + assert.equal(client.size, 1); + cleanup(); + await flushMicrotasks(); + assert.equal(client.size, 0); + assert.equal( + transport.calls.filter((call) => call.type === "chat.unsubscribe").length, + 1, + ); +}); + +test("disconnect clears events buffered before the subscribe response", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + transport.setResponder(() => gate.then(() => subscribeResponse({ latest_seq: 1 }))); + client.subscribe("conv-1", handlers); + client.handleConnected(); + + // Events buffered while the subscribe response is in flight belong to the + // dying connection; after a disconnect the resume protocol re-fetches + // everything, so draining them later would corrupt the transcript. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "stale" }); + client.handleDisconnected(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 3 })); + client.handleConnected(); + release(); + await flushMicrotasks(); + + assert.equal(seen.events.length, 0, "stale buffered events were dropped"); + assert.ok(seen.syncs.length >= 1); + assert.equal(seen.syncs.at(-1).latestSeq, 3); +}); + diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index a819057f0..004987850 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -2,27 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; -class FakeMessagePort { - messages = []; - closed = false; - onmessage = null; - onmessageerror = null; - - postMessage(message) { - this.messages.push(message); - } - - start() {} - - close() { - this.closed = true; - } - - emit(data) { - this.onmessage?.({ data }); - } -} - class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; @@ -115,17 +94,6 @@ function installBrowser(options = {}) { }; } -class FakeSharedWorker { - static instances = []; - - constructor(url, options) { - this.url = url; - this.options = options; - this.port = new FakeMessagePort(); - FakeSharedWorker.instances.push(this); - } -} - function waitFor(predicate, label) { return new Promise((resolve, reject) => { const startedAt = Date.now(); @@ -411,1406 +379,12 @@ test("GatewayWebSocketClient does not recover mutating git requests", async () = const client = getGatewayWebSocketClient(" token "); const stagePromise = client.gitRequest("stage", "/workspace/project", { path: "src/main.rs" }); const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((message) => message.type === "git.stage"), "git.stage envelope"); - socket.close({ code: 1006, wasClean: false }); - - await assert.rejects(stagePromise, /Gateway WebSocket disconnected/); - assert.equal(FakeWebSocket.instances.length, 1); - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client sends conversation cancel directly over HTTP", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const realFetch = globalThis.fetch; - const fetchCalls = []; - globalThis.fetch = async (rawUrl, init = {}) => { - fetchCalls.push({ url: new URL(String(rawUrl)), init }); - return new Response(JSON.stringify({ accepted: true }), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - await client.cancelChat(" conversation-1 ", " run-1 "); - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.toString(), "https://gateway.example/api/chat/commands"); - assert.equal(fetchCalls[0].init.method, "POST"); - assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer token"); - assert.equal(fetchCalls[0].init.headers["X-LiveAgent-CSRF"], "1"); - assert.deepEqual(JSON.parse(fetchCalls[0].init.body), { - type: "chat.cancel", - payload: { - run_id: "run-1", - conversation_id: "conversation-1", - }, - }); - assert.equal( - port.messages.some((message) => message.type === "request" && message.method === "chat.cancel"), - false, - ); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("SharedWorker gateway client emits chat queue snapshots from worker events", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const snapshots = []; - client.subscribeChatQueue((snapshot) => snapshots.push(snapshot)); - const snapshot = { - conversationId: "conversation-1", - revision: 7, - items: [ - { - id: "queue-1", - previewText: "next prompt", - fileCount: 0, - createdAt: 123, - source: "gui", - editable: true, - }, - ], - }; - port.emit({ - type: "event", - event_type: "chat_queue", - connection_id: connect.connection_id, - payload: snapshot, - }); - - assert.deepEqual(snapshots, [snapshot]); - resetGatewayWebSocketClient(); -}); - -test("GatewayWebSocketClient streamChatEvents sends replay cursor", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: " live-run ", - afterSeq: 41, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.pathname, "/api/chat/events"); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "live-run"); - assert.equal(fetchCalls[0].url.searchParams.get("conversation_id"), "conversation-1"); - assert.equal(fetchCalls[0].url.searchParams.get("after_seq"), "41"); - assert.equal(fetchCalls[0].init.method, "GET"); - assert.equal(fetchCalls[0].init.headers.Accept, "text/event-stream"); - assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer token"); - assert.equal(fetchCalls[0].init.headers["Last-Event-ID"], "41"); - assert.deepEqual(events, [{ type: "done", conversation_id: "conversation-1", seq: 42 }]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents resumes from the last delivered event id", async () => { - installBrowser({ - setTimeout: (fn, _delay, ...args) => setTimeout(fn, 0, ...args), - }); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const attempt = fetchCalls.length; - const body = new ReadableStream({ - start(controller) { - if (attempt === 1) { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"token","text":"hello","conversation_id":"conversation-1"}}\n\n', - ), - ); - } else { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"token","text":"duplicate","conversation_id":"conversation-1"}}\n\n' + - 'id: 43\nevent: chat.event\ndata: {"seq":43,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - } - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { afterSeq: 41 })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 2); - assert.equal(fetchCalls[0].url.searchParams.get("after_seq"), "41"); - assert.equal(fetchCalls[0].init.headers["Last-Event-ID"], "41"); - assert.equal(fetchCalls[1].url.searchParams.get("after_seq"), "42"); - assert.equal(fetchCalls[1].init.headers["Last-Event-ID"], "42"); - assert.deepEqual(events, [ - { type: "token", text: "hello", conversation_id: "conversation-1", seq: 42 }, - { type: "done", conversation_id: "conversation-1", seq: 43 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents ignores stale terminal events from older runs", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"live-run","seq":3,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n' + - 'id: 4\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":4,"payload":{"type":"token","text":"second","conversation_id":"conversation-1"}}\n\n' + - 'id: 5\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":5,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { afterSeq: 0 })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.deepEqual(events, [ - { type: "token", text: "second", conversation_id: "conversation-1", seq: 4 }, - { type: "done", conversation_id: "conversation-1", seq: 5 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents ignores stale non-terminal events from older runs", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"live-run","seq":3,"payload":{"type":"token","text":"stale","conversation_id":"conversation-1"}}\n\n' + - 'id: 4\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":4,"payload":{"type":"token","text":"fresh","conversation_id":"conversation-1"}}\n\n' + - 'id: 5\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":5,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: "live-run", - afterSeq: 0, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "live-run"); - assert.deepEqual(events, [ - { type: "token", text: "fresh", conversation_id: "conversation-1", seq: 4 }, - { type: "done", conversation_id: "conversation-1", seq: 5 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents isolates a run after interrupt cancellation", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.control\ndata: {"run_id":"old-run","snapshot_run_id":"new-run","seq":1,"payload":{"type":"cancelled","state":"cancelled","conversation_id":"conversation-1","seq":1}}\n\n' + - 'id: 2\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"new-run","seq":2,"payload":{"type":"token","text":"stale tail","conversation_id":"conversation-1","seq":2}}\n\n' + - 'id: 1\nevent: chat.control\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":1,"payload":{"type":"started","state":"running","conversation_id":"conversation-1","seq":1}}\n\n' + - 'id: 2\nevent: chat.event\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":2,"payload":{"type":"token","text":"fresh","conversation_id":"conversation-1","seq":2}}\n\n' + - 'id: 3\nevent: chat.event\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":3,"payload":{"type":"done","conversation_id":"conversation-1","seq":3}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: "new-run", - afterSeq: 0, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "new-run"); - assert.deepEqual(events, [ - { type: "started", state: "running", conversation_id: "conversation-1", seq: 1 }, - { type: "token", text: "fresh", conversation_id: "conversation-1", seq: 2 }, - { type: "done", conversation_id: "conversation-1", seq: 3 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("SharedWorker gateway client forwards foreground wakeups to the worker", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - window.dispatchEvent({ type: "pageshow" }); - - assert.deepEqual(port.messages.at(-1), { - type: "wakeup", - connection_id: connect.connection_id, - }); - - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client accepts terminal list sessions from worker payload", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const sessionsPromise = client.listTerminals("/workspace/project"); - await waitFor( - () => port.messages.some((message) => message.method === "terminal.list"), - "shared worker terminal.list request", - ); - const request = port.messages.find((message) => message.method === "terminal.list"); - assert.deepEqual(request.payload, { project_path_key: "/workspace/project" }); - - port.emit({ - type: "response", - connection_id: connect.connection_id, - request_id: request.request_id, - payload: { - sessions: [ - { - id: "terminal-1", - project_path_key: "/workspace/project", - cwd: "/workspace/project", - title: "Terminal 1", - created_at: 1, - updated_at: 2, - running: true, - }, - ], - }, - }); - - const sessions = await sessionsPromise; - assert.equal(sessions.length, 1); - assert.equal(sessions[0].id, "terminal-1"); - assert.equal(sessions[0].projectPathKey, "/workspace/project"); - assert.equal(sessions[0].title, "Terminal 1"); - - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client keeps SSH create snapshots from worker payload", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const createPromise = client.createSshTerminal({ - cwd: "/workspace/project", - projectPathKey: "project-key", - hostId: "host-1", - }); - await waitFor( - () => port.messages.some((message) => message.type === "request" && message.method === "terminal.create_ssh"), - "terminal.create_ssh worker request", - ); - const request = port.messages.find((message) => message.type === "request" && message.method === "terminal.create_ssh"); - assert.ok(request); - assert.deepEqual(request.payload, { - cwd: "/workspace/project", - project_path_key: "project-key", - ssh_host_id: "host-1", - title: undefined, - cols: undefined, - rows: undefined, - sftp_enabled: false, - }); - port.emit({ - type: "response", - connection_id: connect.connection_id, - request_id: request.request_id, - payload: { - snapshot: { - session: { - id: "ssh-1", - projectPathKey: "project-key", - cwd: "/workspace/project", - shell: "ssh", - title: "Claw-SG", - kind: "ssh", - ssh: { - hostId: "host-1", - hostName: "Claw-SG", - username: "root", - host: "8.219.204.112", - port: 22, - authType: "privateKey", - status: "connected", - reconnectAttempt: 0, - reconnectMaxAttempts: 3, - }, - pid: null, - cols: 80, - rows: 24, - createdAt: 10, - updatedAt: 10, - running: true, - }, - output: "root@s878169:~# ", - truncated: false, - outputStartOffset: 0, - outputEndOffset: 18, - }, - }, - }); - - const result = await createPromise; - assert.equal(result.snapshot?.session.id, "ssh-1"); - assert.equal(result.snapshot?.session.ssh?.hostId, "host-1"); - assert.equal(result.snapshot?.output, "root@s878169:~# "); - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client posts chat commands directly over HTTP", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const realFetch = globalThis.fetch; - const encoder = new TextEncoder(); - const fetchCalls = []; - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname === "/api/chat/commands") { - return new Response( - JSON.stringify({ - run_id: "run-1", - conversation_id: "conversation-1", - accepted_seq: 1, - }), - { - status: 202, - headers: { "Content-Type": "application/json" }, - }, - ); - } - if (url.pathname === "/api/chat/events") { - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.event\ndata: {"seq":1,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - } - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - }; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const events = []; - for await (const event of client.commandChat({ - type: "chat.submit", - message: "hello", - conversationId: "conversation-1", - selectedModel: { - customProviderId: "claude-provider", - model: "claude-test", - providerType: "claude_code", - }, - systemSettings: { - executionMode: "agent-dev", - workdir: "/workspace", - selectedSystemTools: ["http_get_test"], - }, - uploadedFiles: [ - { - relativePath: "uploads/notes.txt", - absolutePath: "/workspace/uploads/notes.txt", - fileName: "notes.txt", - kind: "text", - sizeBytes: 12, - }, - ], - clientRequestId: "client-submit-1", - runtimeControls: { - thinkingEnabled: false, - nativeWebSearchEnabled: true, - reasoning: "xhigh", - }, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 2); - assert.equal(fetchCalls[0].url.toString(), "https://gateway.example/api/chat/commands"); - assert.deepEqual(JSON.parse(fetchCalls[0].init.body), { - type: "chat.submit", - payload: { - message: "hello", - conversation_id: "conversation-1", - client_request_id: "client-submit-1", - execution_mode: "agent-dev", - workdir: "/workspace", - selected_system_tools: ["http_get_test"], - uploaded_files: [ - { - relative_path: "uploads/notes.txt", - absolute_path: "/workspace/uploads/notes.txt", - file_name: "notes.txt", - kind: "text", - size_bytes: 12, - }, - ], - selected_model: { - custom_provider_id: "claude-provider", - model: "claude-test", - provider_type: "claude_code", - }, - runtime_controls: { - thinking_enabled: false, - native_web_search_enabled: true, - reasoning: "xhigh", - }, - queue_policy: "auto", - }, - }); - assert.equal(fetchCalls[1].url.pathname, "/api/chat/events"); - assert.equal(fetchCalls[1].url.searchParams.get("run_id"), "run-1"); - assert.deepEqual(events, [{ type: "done", conversation_id: "conversation-1", seq: 1 }]); - const legacyWorkerChatCommand = "chat" + ".command"; - assert.equal( - port.messages.some( - (message) => message.type === "request" && message.method === legacyWorkerChatCommand, - ), - false, - ); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("Gateway SharedWorker broadcasts events with each port connection id", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - statusListeners = []; - historyListeners = []; - settingsListeners = []; - sftpTransferListeners = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus(listener) { - this.statusListeners.push(listener); - return () => {}; - } - - subscribeHistory(listener) { - this.historyListeners.push(listener); - return () => {}; - } - - subscribeSettings(listener) { - this.settingsListeners.push(listener); - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers(listener) { - this.sftpTransferListeners.push(listener); - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - assert.equal(typeof globalThis.onconnect, "function"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - - firstPort.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - assert.equal(clientInstances.length, 1); - assert.equal(clientInstances[0].token, "token"); - assert.deepEqual(firstPort.messages.at(-1), { - type: "ready", - connection_id: "connection-1", - payload: { status: null, error: null }, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "ready", - connection_id: "connection-2", - payload: { status: null, error: null }, - }); - - const historyEvent = { kind: "idle", conversation_id: "conversation-1" }; - clientInstances[0].historyListeners[0](historyEvent); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "history", - connection_id: "connection-1", - payload: historyEvent, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "history", - connection_id: "connection-2", - payload: historyEvent, - }); - - const sftpEvent = { - kind: "progress", - transfer: { - id: "transfer-1", - sessionId: "ssh-1", - status: "running", - bytesTransferred: 12, - totalBytes: 24, - }, - }; - clientInstances[0].sftpTransferListeners[0](sftpEvent); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "sftp", - connection_id: "connection-1", - payload: sftpEvent, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "sftp", - connection_id: "connection-2", - payload: sftpEvent, - }); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker applies foreground wakeups to the managed socket client", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - wakeups = 0; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - noteForegroundWakeup() { - this.wakeups += 1; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - port.emit({ type: "wakeup", connection_id: "connection-1" }); - - assert.equal(clientInstances.length, 1); - assert.equal(clientInstances[0].wakeups, 1); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker terminal metadata reaches every page while output stays scoped", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - async listTerminals(projectPathKey) { - this.calls.push(["listTerminals", projectPathKey ?? ""]); - return [ - { - id: "terminal-1", - projectPathKey: "/workspace/project-a", - cwd: "/workspace/project-a", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 1, - running: true, - }, - ]; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - firstPort.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - firstPort.emit({ - type: "request", - connection_id: "connection-1", - request_id: "terminal-list-all", - method: "terminal.list", - payload: {}, - }); - await waitFor( - () => firstPort.messages.some((message) => message.request_id === "terminal-list-all"), - "terminal list all response", - ); - assert.deepEqual(clientInstances[0].calls, [["listTerminals", ""]]); - const listResponse = firstPort.messages.find( - (message) => message.request_id === "terminal-list-all", - ); - assert.equal(listResponse.payload.sessions[0].id, "terminal-1"); - - const event = { - kind: "created", - sessionId: "terminal-2", - projectPathKey: "/workspace/project-b", - session: { - id: "terminal-2", - projectPathKey: "/workspace/project-b", - cwd: "/workspace/project-b", - shell: "zsh", - title: "Terminal 2", - cols: 80, - rows: 24, - createdAt: 2, - updatedAt: 2, - running: true, - }, - }; - clientInstances[0].terminalListeners[0](event); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "terminal", - payload: event, - connection_id: "connection-1", - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "terminal", - payload: event, - connection_id: "connection-2", - }); - - const outputEvent = { - ...event, - kind: "output", - data: "secret\n", - }; - clientInstances[0].terminalListeners[0](outputEvent); - - assert.equal( - firstPort.messages.some((message) => message.payload === outputEvent), - false, - ); - assert.equal( - secondPort.messages.some((message) => message.payload === outputEvent), - false, - ); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker forwards terminal stream snapshot and output messages", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - const streamSession = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; - let resolveAttach = null; - const outputListeners = []; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - terminalStream = { - attach: (session, options) => { - this.calls.push(["terminalStream.attach", session.id, options?.maxBytes]); - return new Promise((resolve) => { - resolveAttach = () => { - resolve({ - snapshot: { - session, - bytes: new Uint8Array([112, 119, 100]), - truncated: false, - outputStartOffset: 10, - outputEndOffset: 13, - }, - write: (bytes) => { - this.calls.push(["terminalStream.write", [...bytes]]); - return true; - }, - resize: (cols, rows) => this.calls.push(["terminalStream.resize", cols, rows]), - dispose: () => this.calls.push(["terminalStream.dispose", session.id]), - subscribeOutput: (listener) => { - outputListeners.push(listener); - return () => {}; - }, - subscribeInputState: () => () => {}, - }); - }; - }); - }, - }; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - port.emit({ - type: "terminal_stream_attach", - connection_id: "connection-1", - stream_id: "page-stream-1", - session: streamSession, - max_bytes: 8192, - }); - await waitFor( - () => clientInstances[0]?.calls.some((call) => call[0] === "terminalStream.attach"), - "terminal stream attach", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "terminalStream.attach", - "terminal-1", - 8192, - ]); - - resolveAttach(); - await waitFor( - () => port.messages.some((message) => message.type === "terminal_stream_snapshot"), - "terminal stream snapshot", - ); - const snapshotMessage = port.messages.find((message) => message.type === "terminal_stream_snapshot"); - assert.equal(snapshotMessage.connection_id, "connection-1"); - assert.equal(snapshotMessage.stream_id, "page-stream-1"); - assert.deepEqual([...snapshotMessage.payload.bytes], [112, 119, 100]); - assert.equal(snapshotMessage.payload.outputStartOffset, 10); - - outputListeners[0]({ - sessionId: "terminal-1", - projectPathKey: "/workspace/project", - bytes: new Uint8Array([13, 10]), - startOffset: 13, - endOffset: 15, - }); - await waitFor( - () => port.messages.some((message) => message.type === "terminal_stream_output"), - "terminal stream output", - ); - const outputMessage = port.messages.find((message) => message.type === "terminal_stream_output"); - assert.equal(outputMessage.connection_id, "connection-1"); - assert.equal(outputMessage.stream_id, "page-stream-1"); - assert.deepEqual([...outputMessage.payload.bytes], [13, 10]); - assert.equal(outputMessage.payload.startOffset, 13); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker keeps one upstream terminal stream until every port detaches", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - const streamSession = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; - const outputListeners = []; - const inputStateListeners = []; - const currentInputState = { - paused: false, - queuedBytes: 17, - highWaterBytes: 256 * 1024, - }; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - terminalStream = { - attach: async (session, options) => { - this.calls.push(["terminalStream.attach", session.id, options?.maxBytes]); - return { - snapshot: { - session, - bytes: new Uint8Array(), - truncated: false, - outputStartOffset: 0, - outputEndOffset: 0, - }, - write: (bytes) => { - this.calls.push(["terminalStream.write", [...bytes]]); - return true; - }, - resize: (cols, rows) => this.calls.push(["terminalStream.resize", cols, rows]), - dispose: () => this.calls.push(["terminalStream.dispose", session.id]), - subscribeOutput: (listener) => { - outputListeners.push(listener); - return () => this.calls.push(["terminalStream.unsubscribe", session.id]); - }, - subscribeInputState: (listener) => { - inputStateListeners.push(listener); - listener(currentInputState); - return () => this.calls.push(["terminalStream.inputState.unsubscribe", session.id]); - }, - }; - }, - }; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - firstPort.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - firstPort.emit({ - type: "terminal_stream_attach", - connection_id: "connection-1", - stream_id: "first-stream", - session: streamSession, - max_bytes: 4096, - }); - await waitFor( - () => firstPort.messages.some((message) => message.type === "terminal_stream_snapshot"), - "first terminal stream snapshot", - ); - - secondPort.emit({ - type: "terminal_stream_attach", - connection_id: "connection-2", - stream_id: "second-stream", - session: streamSession, - max_bytes: 4096, - }); - await waitFor( - () => - secondPort.messages.some((message) => message.type === "terminal_stream_snapshot") && - secondPort.messages.some((message) => message.type === "terminal_stream_input_state"), - "second terminal stream snapshot and input state", - ); - assert.equal( - clientInstances[0].calls.filter((call) => call[0] === "terminalStream.attach").length, - 1, - ); - assert.equal(inputStateListeners.length, 1); - const secondInputState = secondPort.messages.find( - (message) => message.type === "terminal_stream_input_state", - ); - assert.equal(secondInputState.stream_id, "second-stream"); - assert.deepEqual(secondInputState.payload, currentInputState); - - firstPort.emit({ - type: "terminal_stream_write", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - bytes: new Uint8Array([97, 98]), - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.write"), - "terminal stream write", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.write", [97, 98]]); - - firstPort.emit({ - type: "terminal_stream_resize", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - cols: 100, - rows: 30, - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.resize"), - "terminal stream resize", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.resize", 100, 30]); - - firstPort.emit({ - type: "terminal_stream_detach", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.equal( - clientInstances[0].calls.some((call) => call[0] === "terminalStream.dispose"), - false, - ); - - const firstPortMessageCount = firstPort.messages.length; - outputListeners[0]({ - sessionId: "terminal-1", - projectPathKey: "/workspace/project", - bytes: new Uint8Array([112, 119, 100]), - startOffset: 0, - endOffset: 3, - }); - assert.equal(firstPort.messages.length, firstPortMessageCount); - assert.equal(secondPort.messages.at(-1).type, "terminal_stream_output"); - assert.equal(secondPort.messages.at(-1).stream_id, "second-stream"); - assert.deepEqual([...secondPort.messages.at(-1).payload.bytes], [112, 119, 100]); - - secondPort.emit({ - type: "terminal_stream_detach", - connection_id: "connection-2", - stream_id: "second-stream", - session_id: "terminal-1", - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.dispose"), - "upstream terminal stream dispose", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.dispose", "terminal-1"]); + await waitFor(() => socket.sent.some((message) => message.type === "git.stage"), "git.stage envelope"); + socket.close({ code: 1006, wasClean: false }); - globalThis.onconnect = previousOnConnect; + await assert.rejects(stagePromise, /Gateway WebSocket disconnected/); + assert.equal(FakeWebSocket.instances.length, 1); + resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient sends mention query payloads", async () => { @@ -2023,10 +597,10 @@ test("GatewayWebSocketClient sends history list requests", async () => { payload: { conversations: [], total_count: 0, - running_conversation_ids: ["conversation-running"], running_conversations: [ { conversation_id: "conversation-running", + run_id: "run-running", cwd: "/tmp/project-a", updated_at: 123, }, @@ -2036,10 +610,10 @@ test("GatewayWebSocketClient sends history list requests", async () => { assert.deepEqual(await listPromise, { conversations: [], total_count: 0, - running_conversation_ids: ["conversation-running"], running_conversations: [ { conversation_id: "conversation-running", + run_id: "run-running", cwd: "/tmp/project-a", updated_at: 123, }, @@ -2079,12 +653,12 @@ test("GatewayWebSocketClient sends project-aware history and fs requests", async socket.receive({ id: socket.sent[1].id, type: "response", - payload: { conversations: [], total_count: 0, running_conversation_ids: [] }, + payload: { conversations: [], total_count: 0, running_conversations: [] }, }); assert.deepEqual(await filteredListPromise, { conversations: [], total_count: 0, - running_conversation_ids: [], + running_conversations: [], }); const chatModeListPromise = client.listHistory(1, 80, { cwdEmpty: true }); @@ -2098,12 +672,12 @@ test("GatewayWebSocketClient sends project-aware history and fs requests", async socket.receive({ id: socket.sent[2].id, type: "response", - payload: { conversations: [], total_count: 0, running_conversation_ids: [] }, + payload: { conversations: [], total_count: 0, running_conversations: [] }, }); assert.deepEqual(await chatModeListPromise, { conversations: [], total_count: 0, - running_conversation_ids: [], + running_conversations: [], }); const workdirsPromise = client.listHistoryWorkdirs(); @@ -2154,12 +728,12 @@ test("GatewayWebSocketClient defaults invalid history pagination", async () => { socket.receive({ id: socket.sent[1].id, type: "response", - payload: { conversations: [], total_count: 0, running_conversation_ids: [] }, + payload: { conversations: [], total_count: 0, running_conversations: [] }, }); assert.deepEqual(await listPromise, { conversations: [], total_count: 0, - running_conversation_ids: [], + running_conversations: [], }); const sharedListPromise = client.listSharedHistory(Number.NaN, 500); @@ -2243,363 +817,6 @@ test("GatewayWebSocketClient sends history share requests", async () => { resetGatewayWebSocketClient(); }); -test("Gateway SharedWorker forwards history share requests", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - statusListeners = []; - historyListeners = []; - settingsListeners = []; - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus(listener) { - this.statusListeners.push(listener); - return () => {}; - } - - subscribeHistory(listener) { - this.historyListeners.push(listener); - return () => {}; - } - - subscribeSettings(listener) { - this.settingsListeners.push(listener); - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - getHistoryShare(conversationID) { - this.calls.push(["getHistoryShare", conversationID]); - return { - conversation_id: conversationID, - enabled: false, - token: "", - created_at: 0, - updated_at: 0, - }; - } - - setHistoryShare(conversationID, enabled, options) { - this.calls.push(["setHistoryShare", conversationID, enabled, options]); - return { - conversation_id: conversationID, - enabled, - token: enabled ? "share-token" : "", - created_at: 10, - updated_at: 20, - redact_tool_content: options?.redactToolContent === true, - }; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - assert.equal(clientInstances.length, 1); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "share-get", - method: "history.share.get", - payload: { conversation_id: "conversation-1" }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "share-get"), - "shared worker history share get response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["getHistoryShare", "conversation-1"]); - assert.deepEqual(port.messages.at(-1), { - type: "response", - connection_id: "connection-1", - request_id: "share-get", - payload: { - conversation_id: "conversation-1", - enabled: false, - token: "", - created_at: 0, - updated_at: 0, - }, - error: undefined, - }); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "share-set", - method: "history.share.set", - payload: { - conversation_id: "conversation-1", - enabled: true, - redact_tool_content: true, - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "share-set"), - "shared worker history share set response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "setHistoryShare", - "conversation-1", - true, - { redactToolContent: true }, - ]); - assert.deepEqual(port.messages.at(-1), { - type: "response", - connection_id: "connection-1", - request_id: "share-set", - payload: { - conversation_id: "conversation-1", - enabled: true, - token: "share-token", - created_at: 10, - updated_at: 20, - redact_tool_content: true, - }, - error: undefined, - }); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker forwards tunnel requests", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - listTunnels() { - this.calls.push(["listTunnels"]); - return [ - { - id: "tun-1", - slug: "slug-1", - name: "App", - targetUrl: "http://localhost:3000", - publicUrl: "https://gateway.example/t/slug-1/", - createdAt: 10, - expiresAt: 3700, - activeConnections: 0, - status: "active", - }, - ]; - } - - createTunnel(input) { - this.calls.push(["createTunnel", input]); - return { - id: "tun-2", - slug: "slug-2", - name: input.name ?? "", - targetUrl: input.targetUrl, - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: 920, - activeConnections: 0, - status: "active", - }; - } - - updateTunnel(input) { - this.calls.push(["updateTunnel", input]); - return { - id: input.id, - slug: "slug-2", - name: input.name ?? "", - targetUrl: input.targetUrl, - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: input.ttlSeconds === 0 ? 0 : 920, - activeConnections: 0, - status: "active", - projectPathKey: input.projectPathKey ?? "", - }; - } - - closeTunnel(id) { - this.calls.push(["closeTunnel", id]); - return { - id, - slug: "slug-2", - name: "Closed", - targetUrl: "http://localhost:3000", - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: 920, - activeConnections: 0, - status: "expired", - }; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - assert.equal(clientInstances.length, 1); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-list", - method: "tunnel.list", - payload: {}, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-list"), - "shared worker tunnel list response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["listTunnels"]); - assert.equal(port.messages.at(-1).payload.tunnels[0].id, "tun-1"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-create", - method: "tunnel.create", - payload: { - targetUrl: "http://localhost:3000/app", - ttlSeconds: 900, - name: "App", - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-create"), - "shared worker tunnel create response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "createTunnel", - { - targetUrl: "http://localhost:3000/app", - ttlSeconds: 900, - name: "App", - }, - ]); - assert.equal(port.messages.at(-1).payload.tunnel.id, "tun-2"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-update-infinite", - method: "tunnel.update", - payload: { - id: "tun-2", - targetUrl: "http://localhost:4000/dashboard", - ttlSeconds: 0, - name: "Dashboard", - projectPathKey: "project:/tmp/liveagent", - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-update-infinite"), - "shared worker tunnel update response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "updateTunnel", - { - id: "tun-2", - targetUrl: "http://localhost:4000/dashboard", - ttlSeconds: 0, - name: "Dashboard", - projectPathKey: "project:/tmp/liveagent", - }, - ]); - assert.equal(port.messages.at(-1).payload.tunnel.expiresAt, 0); - assert.equal(port.messages.at(-1).payload.tunnel.projectPathKey, "project:/tmp/liveagent"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-close", - method: "tunnel.close", - payload: { id: "tun-2" }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-close"), - "shared worker tunnel close response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["closeTunnel", "tun-2"]); - assert.equal(port.messages.at(-1).payload.tunnel.status, "expired"); - - globalThis.onconnect = previousOnConnect; -}); - test("GatewayWebSocketClient reconnects before read requests when an authenticated socket goes stale", async () => { installBrowser(); const loader = createWebModuleLoader(); @@ -2622,7 +839,7 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat let mockNow = realDateNow(); Date.now = () => mockNow; - mockNow += 30_000; + mockNow += 46_000; const historyPromise = client.getHistory("conversation-1"); assert.equal(FakeWebSocket.instances.length, 2); @@ -2783,178 +1000,285 @@ test("GatewayWebSocketClient replies to gateway websocket pings", async () => { resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient commandChat posts commands and streams SSE events until done", async () => { +test("GatewayWebSocketClient chatCommand sends the command envelope and parses the accept response", async () => { installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname === "/api/chat/commands") { - return new Response( - JSON.stringify({ - run_id: "run-1", + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const commandPromise = client.chatCommand({ + type: "chat.submit", + message: "hello", + conversationId: "conversation-1", + clientRequestId: "req-1", + queuePolicy: "append", + systemSettings: { + executionMode: "agent", + workdir: "/workspace/project", + selectedSystemTools: ["Bash"], + }, + }); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.command"), "chat command envelope"); + const commandEnvelope = socket.sent.find((item) => item.type === "chat.command"); + assert.equal(commandEnvelope.payload.type, "chat.submit"); + assert.equal(commandEnvelope.payload.payload.message, "hello"); + assert.equal(commandEnvelope.payload.payload.conversation_id, "conversation-1"); + assert.equal(commandEnvelope.payload.payload.client_request_id, "req-1"); + assert.equal(commandEnvelope.payload.payload.queue_policy, "append"); + assert.equal(commandEnvelope.payload.payload.workdir, "/workspace/project"); + + socket.receive({ + id: commandEnvelope.id, + type: "response", + payload: { run_id: " run-1 ", conversation_id: "conversation-1", accepted_seq: 7 }, + }); + assert.deepEqual(await commandPromise, { + runId: "run-1", + conversationId: "conversation-1", + acceptedSeq: 7, + }); + resetGatewayWebSocketClient(); +}); + +test("GatewayWebSocketClient cancelChat sends chat.cancel with conversation and run ids", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const cancelPromise = client.cancelChat(" conversation-1 ", " run-9 "); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.cancel"), "chat.cancel envelope"); + const cancelEnvelope = socket.sent.find((item) => item.type === "chat.cancel"); + assert.deepEqual(cancelEnvelope.payload, { + conversation_id: "conversation-1", + run_id: "run-9", + }); + socket.receive({ id: cancelEnvelope.id, type: "response", payload: {} }); + await cancelPromise; + resetGatewayWebSocketClient(); +}); + +test("GatewayWebSocketClient conversation subscriptions subscribe after auth, route pushes, and survive reconnects", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const seen = { syncs: [], events: [] }; + const cleanup = client.subscribeConversationStream("conversation-1", { + onSync: (result) => seen.syncs.push(result), + onEvent: (event) => seen.events.push(event), + }); + + // The transport may legitimately re-issue chat.subscribe (connect + eager + // ensureConnected both call handleConnected); answer every request with the + // caller's cursor plus any replay events staged below. + const answeredSubscribes = new Set(); + let replayEvents = []; + const subscribeCalls = []; + const answerSubscribes = (socket) => { + for (const envelope of socket.sent) { + if (envelope.type !== "chat.subscribe" || answeredSubscribes.has(envelope.id)) { + continue; + } + answeredSubscribes.add(envelope.id); + subscribeCalls.push(envelope.payload); + const events = replayEvents; + replayEvents = []; + const latestSeq = events.length + ? events[events.length - 1].seq + : Math.max(envelope.payload.after_seq ?? 0, 2); + socket.receive({ + id: envelope.id, + type: "response", + payload: { conversation_id: "conversation-1", - accepted_seq: 1, - }), - { - status: 202, - headers: { "Content-Type": "application/json" }, - }, - ); - } - if (url.pathname === "/api/chat/events") { - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.control\ndata: {"seq":1,"payload":{"type":"accepted","conversation_id":"conversation-1","seq":1}}\n\n', - ), - ); - controller.enqueue( - encoder.encode( - 'id: 2\nevent: chat.event\ndata: {"seq":2,"payload":{"type":"token","text":"hi","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"seq":3,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); + stream_epoch: "epoch-1", + latest_seq: latestSeq, + reset: false, + activity: null, + snapshot: null, + events, }, }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); } - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); }; + const settle = async (socket) => { + for (let i = 0; i < 20; i += 1) { + answerSubscribes(socket); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }; + + // Auth completes → the persistent subscription issues chat.subscribe. + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.subscribe"), "chat.subscribe"); + assert.equal(subscribeCalls.length, 0); + await settle(socket); + assert.ok(seen.syncs.length >= 1, "subscribe sync delivered"); + assert.equal(subscribeCalls[0].conversation_id, "conversation-1"); + assert.equal(subscribeCalls[0].after_seq, 0); + + // chat.event pushes route by conversation id (no subscription id). + socket.receive({ + type: "chat.event", + payload: { type: "run_started", conversation_id: "conversation-1", run_id: "run-1", seq: 3 }, + }); + socket.receive({ + type: "chat.event", + payload: { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 4, text: "hi" }, + }); + socket.receive({ + type: "chat.event", + payload: { type: "token", conversation_id: "conversation-other", run_id: "run-x", seq: 9, text: "ignored" }, + }); + await settle(socket); + assert.deepEqual( + seen.events.map((event) => event.type), + ["run_started", "token"], + ); + + // Disconnect keeps the registration; the reconnect re-subscribes with the + // resume cursor and stream epoch. + const syncsBeforeReconnect = seen.syncs.length; + replayEvents = [ + { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 5, text: "re" }, + { + type: "run_finished", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 6, + status: "completed", + }, + ]; + const subscribesBeforeReconnect = subscribeCalls.length; + socket.close(); + await new Promise((resolve, reject) => { + const startedAt = Date.now(); + const tick = () => { + if (FakeWebSocket.instances.length >= 2) { + resolve(); + return; + } + if (Date.now() - startedAt > 3_000) { + reject(new Error("timed out waiting for reconnect socket")); + return; + } + setTimeout(tick, 10); + }; + tick(); + }); + const reconnectSocket = await connectAndAuth(1); + await settle(reconnectSocket); + assert.ok(seen.syncs.length > syncsBeforeReconnect, "resume sync delivered"); + const resumePayload = subscribeCalls[subscribesBeforeReconnect]; + assert.equal(resumePayload.after_seq, 4, "resume cursor from last delivered seq"); + assert.equal(resumePayload.stream_epoch, "epoch-1"); + const resumeSync = seen.syncs[seen.syncs.length - 1]; + assert.deepEqual( + resumeSync.events.map((event) => event.type), + ["token", "run_finished"], + "replayed events delivered with the resume sync", + ); + + // chat.subscription_reset triggers another resync from the cursor. + const subscribesBeforeReset = subscribeCalls.length; + reconnectSocket.receive({ + type: "chat.subscription_reset", + payload: { conversation_id: "conversation-1" }, + }); + await settle(reconnectSocket); + assert.ok(subscribeCalls.length > subscribesBeforeReset, "reset re-subscribed"); + assert.equal(subscribeCalls[subscribesBeforeReset].after_seq, 6); + + // Cleanup unsubscribes on the wire. + cleanup(); + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.unsubscribe"), + "chat.unsubscribe", + ); + resetGatewayWebSocketClient(); +}); +test("GatewayWebSocketClient fans chat.activity and chat.command_update out to listeners", async () => { + installBrowser(); const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); resetGatewayWebSocketClient(); - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.commandChat({ - type: "chat.submit", - message: "hello", - conversationId: "", - selectedModel: { - customProviderId: "claude-provider", - model: "claude-test", - providerType: "claude_code", - }, - systemSettings: { - executionMode: "agent-dev", - workdir: "/workspace", - selectedSystemTools: ["http_get_test"], - }, - uploadedFiles: [ - { - relativePath: "uploads/notes.txt", - absolutePath: "/workspace/uploads/notes.txt", - fileName: "notes.txt", - kind: "text", - sizeBytes: 12, - }, - { - relativePath: "uploads/screenshot.webp", - absolutePath: "/workspace/uploads/screenshot.webp", - fileName: "screenshot.webp", - kind: "image", - sizeBytes: 34, - }, - { - relativePath: "uploads/report.pdf", - absolutePath: "/workspace/uploads/report.pdf", - fileName: "report.pdf", - kind: "pdf", - sizeBytes: 56, - }, - ], - clientRequestId: "client-submit-1", - runtimeControls: { - thinkingEnabled: false, - nativeWebSearchEnabled: true, - reasoning: "xhigh", - }, - })) { - events.push(event); - } + const client = getGatewayWebSocketClient("token"); + const activityEvents = []; + const commandUpdates = []; + client.subscribeChatActivity((event) => activityEvents.push(event)); + client.subscribeChatCommandUpdates((update) => commandUpdates.push(update)); - assert.equal(fetchCalls.length, 2); - const commandCall = fetchCalls[0]; - assert.equal(commandCall.url.toString(), "https://gateway.example/api/chat/commands"); - assert.equal(commandCall.init.method, "POST"); - assert.equal(commandCall.init.headers.Authorization, "Bearer token"); - assert.equal(commandCall.init.headers["X-LiveAgent-CSRF"], "1"); - assert.deepEqual(JSON.parse(commandCall.init.body), { - type: "chat.submit", - payload: { - message: "hello", - conversation_id: "", - client_request_id: "client-submit-1", - execution_mode: "agent-dev", - workdir: "/workspace", - selected_system_tools: ["http_get_test"], - uploaded_files: [ - { - relative_path: "uploads/notes.txt", - absolute_path: "/workspace/uploads/notes.txt", - file_name: "notes.txt", - kind: "text", - size_bytes: 12, - }, - { - relative_path: "uploads/screenshot.webp", - absolute_path: "/workspace/uploads/screenshot.webp", - file_name: "screenshot.webp", - kind: "image", - size_bytes: 34, - }, - { - relative_path: "uploads/report.pdf", - absolute_path: "/workspace/uploads/report.pdf", - file_name: "report.pdf", - kind: "pdf", - size_bytes: 56, - }, - ], - selected_model: { - custom_provider_id: "claude-provider", - model: "claude-test", - provider_type: "claude_code", - }, - runtime_controls: { - thinking_enabled: false, - native_web_search_enabled: true, - reasoning: "xhigh", - }, - queue_policy: "auto", - }, - }); + const statusPromise = client.getStatus(); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.length >= 2, "status envelope"); + socket.receive({ id: socket.sent[1].id, type: "response", payload: { online: true } }); + await statusPromise; - const eventsCall = fetchCalls[1]; - assert.equal(eventsCall.url.pathname, "/api/chat/events"); - assert.equal(eventsCall.url.searchParams.get("run_id"), "run-1"); - assert.equal(eventsCall.url.searchParams.get("conversation_id"), "conversation-1"); - assert.equal(eventsCall.url.searchParams.get("after_seq"), "0"); - assert.equal(eventsCall.init.method, "GET"); - assert.equal(eventsCall.init.headers.Accept, "text/event-stream"); - assert.equal(eventsCall.init.headers.Authorization, "Bearer token"); - assert.deepEqual(events, [ - { type: "accepted", conversation_id: "conversation-1", seq: 1 }, - { type: "token", text: "hi", conversation_id: "conversation-1", seq: 2 }, - { type: "done", conversation_id: "conversation-1", seq: 3 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } + socket.receive({ + type: "chat.activity", + payload: { + conversation_id: "conversation-1", + run_id: "run-1", + running: true, + state: "running", + workdir: "/workspace/project", + client_request_id: "req-42", + updated_at: 1234, + }, + }); + socket.receive({ + type: "chat.activity", + payload: { conversation_id: "", running: true }, + }); + assert.equal(activityEvents.length, 1, "malformed activity payloads are dropped"); + assert.deepEqual(activityEvents[0], { + conversationId: "conversation-1", + runId: "run-1", + running: true, + state: "running", + workdir: "/workspace/project", + clientRequestId: "req-42", + updatedAt: 1234, + }); + + socket.receive({ + type: "chat.command_update", + payload: { + run_id: "run-1", + client_request_id: "req-1", + conversation_id: "conversation-9", + phase: "bound", + }, + }); + socket.receive({ + type: "chat.command_update", + payload: { run_id: "run-1", phase: "unknown-phase" }, + }); + assert.equal(commandUpdates.length, 1, "unknown phases are dropped"); + assert.deepEqual(commandUpdates[0], { + runId: "run-1", + clientRequestId: "req-1", + conversationId: "conversation-9", + phase: "bound", + errorCode: null, + message: null, + }); + resetGatewayWebSocketClient(); }); diff --git a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs index bcef0dd72..ef8662a5e 100644 --- a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs +++ b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs @@ -3,14 +3,29 @@ import test from "node:test"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; const loader = createWebModuleLoader(); -const historySync = loader.loadModule("src/lib/historySync.ts"); const chatUi = loader.loadModule("src/lib/chatUi.ts"); -const liveStore = loader.loadModule("src/lib/liveConversationStreamStore.ts"); -const liveCommit = loader.loadModule("src/lib/liveConversationCommit.ts"); +const transcriptStoreModule = loader.loadModule("src/lib/chat/transcript/transcriptStore.ts"); +const { createTurn, applyEventToTurn } = loader.loadModule( + "src/lib/chat/transcript/turnReducer.ts", +); +const { buildRowsFromEntries } = loader.loadModule("src/lib/chat/transcript/rows.ts"); const historyShare = loader.loadModule("src/lib/historyShare.ts"); -const requestContextSanitizer = loader.loadModule("src/lib/chat/requestContextSanitizer.ts"); const conversationState = loader.loadModule("src/lib/chat/conversationState.ts"); +// Live-stream reducer harness: the createTurn/applyEventToTurn pair replaces +// the old flat pushChatEvent pipeline — one Turn holds a single run's entries. +function reduceTurnEvents(events) { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + for (const event of events) { + turn = applyEventToTurn(turn, event); + } + return turn; +} + +function findAssistantRow(rows) { + return rows.find((row) => row.kind === "assistant"); +} + test("history share helpers parse and build share URLs", () => { assert.equal(historyShare.parseHistoryShareToken("/share/abc123"), "abc123"); assert.equal(historyShare.parseHistoryShareToken("/share/abc%20123"), "abc 123"); @@ -76,401 +91,6 @@ test("fetchSharedHistory reads public share details that parse into transcript e } }); -test("applyGatewayHistoryEvent upserts newest summaries and removes deleted conversations", () => { - const existing = [ - { id: "one", title: "One", created_at: 1, updated_at: 1, message_count: 1 }, - { id: "two", title: "Two", created_at: 2, updated_at: 2, message_count: 2 }, - ]; - - const upserted = historySync.applyGatewayHistoryEvent(existing, { - kind: "upsert", - conversation_id: "two", - conversation: { id: "two", title: "Updated", created_at: 2, updated_at: 3, message_count: 3 }, - }); - assert.deepEqual(upserted.map((item) => item.id), ["two", "one"]); - assert.equal(upserted[0].title, "Updated"); - - const deleted = historySync.applyGatewayHistoryEvent(upserted, { - kind: "delete", - conversation_id: "two", - }); - assert.deepEqual(deleted.map((item) => item.id), ["one"]); - - const running = historySync.applyGatewayHistoryEvent(deleted, { - kind: "running", - conversation_id: "one", - }); - assert.equal(running, deleted); -}); - -test("applyGatewayHistoryEvent sorts pinned conversations before recent conversations", () => { - const existing = [ - { id: "older", title: "Older", created_at: 1, updated_at: 1, message_count: 1 }, - { id: "newer", title: "Newer", created_at: 2, updated_at: 2, message_count: 2 }, - ]; - - const pinned = historySync.applyGatewayHistoryEvent(existing, { - kind: "upsert", - conversation_id: "older", - conversation: { - id: "older", - title: "Older", - created_at: 1, - updated_at: 1, - message_count: 1, - is_pinned: true, - pinned_at: 3, - }, - }); - assert.deepEqual(pinned.map((item) => item.id), ["older", "newer"]); - - const unpinned = historySync.applyGatewayHistoryEvent(pinned, { - kind: "upsert", - conversation_id: "older", - conversation: { - id: "older", - title: "Older", - created_at: 1, - updated_at: 1, - message_count: 1, - is_pinned: false, - pinned_at: 0, - }, - }); - assert.deepEqual(unpinned.map((item) => item.id), ["newer", "older"]); -}); - -test("applyGatewayHistoryEvent preserves share state when partial summaries arrive", () => { - const existing = [ - { - id: "shared", - title: "Shared", - created_at: 1, - updated_at: 1, - message_count: 1, - is_shared: true, - }, - ]; - - const renamed = historySync.applyGatewayHistoryEvent(existing, { - kind: "upsert", - conversation_id: "shared", - conversation: { - id: "shared", - title: "Renamed", - created_at: 1, - updated_at: 2, - message_count: 1, - }, - }); - - assert.equal(renamed[0].title, "Renamed"); - assert.equal(renamed[0].is_shared, true); - - const disabled = historySync.applyGatewayHistoryEvent(renamed, { - kind: "upsert", - conversation_id: "shared", - conversation: { - id: "shared", - title: "Renamed", - created_at: 1, - updated_at: 3, - message_count: 1, - is_shared: false, - }, - }); - - assert.equal(disabled[0].is_shared, false); -}); - -test("upsertConversationSummary keeps existing title when partial summary title is blank", () => { - const existing = [ - { - id: "conversation-1", - title: "Existing title", - created_at: 1, - updated_at: 10, - message_count: 4, - provider_id: "codex", - model: "gpt-5.2", - session_id: "session-1", - cwd: "/workspace", - }, - ]; - - const updated = historySync.upsertConversationSummary(existing, { - id: "conversation-1", - title: "", - created_at: 1, - updated_at: 11, - message_count: 1, - provider_id: "", - model: "", - session_id: "", - cwd: "", - }); - - assert.equal(updated[0].title, "Existing title"); - assert.equal(updated[0].provider_id, "codex"); - assert.equal(updated[0].model, "gpt-5.2"); - assert.equal(updated[0].session_id, "session-1"); - assert.equal(updated[0].cwd, "/workspace"); - assert.equal(updated[0].updated_at, 11); - assert.equal(updated[0].message_count, 1); -}); - -test("upsertConversationSummary can update titles without changing list position", () => { - const existing = [ - { - id: "newer", - title: "Newer", - created_at: 1, - updated_at: 300, - message_count: 4, - }, - { - id: "conversation-1", - title: "1234567890", - created_at: 1, - updated_at: 100, - message_count: 1, - }, - ]; - - const titleOnly = historySync.upsertConversationSummary( - existing, - { - id: "conversation-1", - title: "Generated title", - created_at: 1, - updated_at: 400, - message_count: 1, - }, - { preserveExistingUpdatedAt: true }, - ); - - assert.deepEqual(titleOnly.map((item) => item.id), ["newer", "conversation-1"]); - assert.equal(titleOnly[1].title, "Generated title"); - assert.equal(titleOnly[1].updated_at, 100); - - const recencyUpdate = historySync.upsertConversationSummary(existing, { - id: "conversation-1", - title: "Generated title", - created_at: 1, - updated_at: 400, - message_count: 1, - }); - - assert.deepEqual(recencyUpdate.map((item) => item.id), ["conversation-1", "newer"]); - assert.equal(recencyUpdate[0].updated_at, 400); -}); - -test("reconcileConversationSummaries keeps optimistic titles during protected refreshes", () => { - const existing = [ - { - id: "conversation-1", - title: "1234567890", - created_at: 100, - updated_at: 100, - message_count: 1, - }, - ]; - - const refreshed = historySync.reconcileConversationSummaries( - existing, - [ - { - id: "conversation-1", - title: "1234567890abcdef", - created_at: 100, - updated_at: 200, - message_count: 2, - }, - ], - { preserveTitleConversationIds: ["conversation-1"] }, - ); - - assert.equal(refreshed[0].title, "1234567890"); - assert.equal(refreshed[0].updated_at, 200); - assert.equal(refreshed[0].message_count, 2); -}); - -test("reconcileConversationSummaries can keep protected rows in place during refreshes", () => { - const existing = [ - { - id: "newer", - title: "Newer", - created_at: 1, - updated_at: 300, - message_count: 4, - }, - { - id: "conversation-1", - title: "1234567890", - created_at: 1, - updated_at: 100, - message_count: 1, - }, - ]; - - const refreshed = historySync.reconcileConversationSummaries( - existing, - [ - { - id: "conversation-1", - title: "Backend title", - created_at: 1, - updated_at: 400, - message_count: 2, - }, - { - id: "newer", - title: "Newer", - created_at: 1, - updated_at: 300, - message_count: 4, - }, - ], - { - preserveTitleConversationIds: ["conversation-1"], - preserveUpdatedAtConversationIds: ["conversation-1"], - }, - ); - - assert.deepEqual(refreshed.map((item) => item.id), ["newer", "conversation-1"]); - assert.equal(refreshed[1].title, "1234567890"); - assert.equal(refreshed[1].updated_at, 100); - assert.equal(refreshed[1].message_count, 2); -}); - -test("reconcileConversationSummaries retains running local rows missing from a list refresh", () => { - const existing = [ - { - id: "__local_draft__:1", - title: "1234567890", - created_at: 100, - updated_at: 100, - message_count: 1, - }, - ]; - - const refreshed = historySync.reconcileConversationSummaries(existing, [], { - retainConversationIds: ["__local_draft__:1"], - }); - - assert.equal(refreshed[0], existing[0]); -}); - -test("normalizeRunningConversationIds trims drops invalid entries and dedupes in order", () => { - assert.deepEqual( - historySync.normalizeRunningConversationIds([ - " conversation-1 ", - "", - "conversation-2", - "conversation-1", - null, - 42, - " conversation-3 ", - ]), - ["conversation-1", "conversation-2", "conversation-3"], - ); - assert.deepEqual(historySync.normalizeRunningConversationIds(undefined), []); -}); - -test("normalizeRunningConversations preserves replay cursors and merges fallback ids", () => { - assert.deepEqual( - historySync.normalizeRunningConversations( - [ - { - conversation_id: " conversation-1 ", - run_id: " run-1 ", - cwd: " /workspace ", - first_seq: 42.9, - run_epoch: 3.2, - updated_at: 123, - }, - { - conversation_id: "conversation-1", - first_seq: 7, - }, - { - conversation_id: "conversation-2", - first_seq: 0, - }, - ], - ["conversation-2", " conversation-3 "], - ), - [ - { - conversation_id: "conversation-1", - run_id: "run-1", - cwd: "/workspace", - first_seq: 42, - run_epoch: 3, - updated_at: 123, - }, - { - conversation_id: "conversation-2", - run_id: undefined, - cwd: undefined, - first_seq: undefined, - run_epoch: undefined, - updated_at: undefined, - }, - { - conversation_id: "conversation-3", - }, - ], - ); -}); - -test("resolveRunningConversationStreamAfterSeq starts remote replay at current run boundary", () => { - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(42), 41); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(42.9), 41); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(1), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(0), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(undefined), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq("42"), 0); - assert.equal( - historySync.resolveRunningConversationStreamAfterSeq(42, { runId: "chat-command-1" }), - 0, - ); -}); - -test("applyGatewayHistoryEvent can protect optimistic titles from summary broadcasts", () => { - const existing = [ - { - id: "conversation-1", - title: "1234567890", - created_at: 100, - updated_at: 100, - message_count: 1, - }, - ]; - const event = { - kind: "upsert", - conversation_id: "conversation-1", - conversation: { - id: "conversation-1", - title: "1234567890abcdef", - created_at: 100, - updated_at: 200, - message_count: 2, - }, - }; - - const protectedTitle = historySync.applyGatewayHistoryEvent(existing, event, { - preserveTitleConversationIds: ["conversation-1"], - preserveUpdatedAtConversationIds: ["conversation-1"], - }); - assert.equal(protectedTitle[0].title, "1234567890"); - assert.equal(protectedTitle[0].updated_at, 100); - - const replacedTitle = historySync.applyGatewayHistoryEvent(existing, event); - assert.equal(replacedTitle[0].title, "1234567890abcdef"); - assert.equal(replacedTitle[0].updated_at, 200); -}); - test("parseHistoryMessagesJson preserves upload display text and checkpoint metadata", () => { const entries = chatUi.parseHistoryMessagesJson(JSON.stringify([ { @@ -616,8 +236,7 @@ test("parseHistoryMessagesJson preserves provider tool_use input arguments", () }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -629,97 +248,6 @@ test("parseHistoryMessagesJson preserves provider tool_use input arguments", () }); }); -test("WebUI model request sanitizer textifies hosted search and drops aborted rounds", () => { - const completedSearch = { - type: "hostedSearch", - id: "hosted-search-completed", - provider: "claude_code", - status: "completed", - queries: ["LiveAgent DeepSeek web search"], - sources: [{ url: "https://example.com/result", title: "Result" }], - }; - const abortedSearch = { - type: "hostedSearch", - id: "hosted-search-aborted", - provider: "claude_code", - status: "completed", - queries: ["aborted query"], - sources: [{ url: "https://example.com/aborted" }], - }; - const toolResult = { - role: "toolResult", - toolCallId: "call-aborted", - toolName: "Read", - content: [{ type: "text", text: "partial" }], - isError: false, - timestamp: 3, - }; - - const sanitized = requestContextSanitizer.sanitizeContextForModelRequest({ - messages: [ - { role: "user", content: "search", timestamp: 1 }, - { - role: "assistant", - content: [{ type: "thinking", thinking: "searching" }, completedSearch], - provider: "anthropic", - model: "deepseek-chat", - api: "anthropic-messages", - usage: { totalTokens: 1 }, - stopReason: "stop", - timestamp: 2, - }, - { - role: "assistant", - content: [abortedSearch], - provider: "anthropic", - model: "deepseek-chat", - api: "anthropic-messages", - usage: { totalTokens: 1 }, - stopReason: "aborted", - timestamp: 3, - }, - toolResult, - ], - }); - - assert.deepEqual(sanitized.messages.map((message) => message.role), ["user", "assistant"]); - assert.deepEqual( - sanitized.messages[1].content.map((block) => block.type), - ["thinking", "text"], - ); - assert.match(sanitized.messages[1].content[1].text, /Provider-hosted web search completed/); - assert.match(sanitized.messages[1].content[1].text, /https:\/\/example\.com\/result/); - assert.doesNotMatch(JSON.stringify(sanitized.messages), /hosted-search-aborted/); - - const state = conversationState.createConversationStateFromContext({ - messages: [ - { role: "user", content: "search", timestamp: 1 }, - { - role: "assistant", - content: [abortedSearch], - provider: "anthropic", - model: "deepseek-chat", - api: "anthropic-messages", - usage: { totalTokens: 1 }, - stopReason: "aborted", - timestamp: 2, - }, - toolResult, - { role: "user", content: "continue", timestamp: 4 }, - ], - }); - assert.deepEqual( - conversationState.buildRequestContext(state).messages.map((message) => message.role), - ["user", "user"], - ); - assert.deepEqual( - conversationState.buildRequestContext(state, { includeAbortedMessages: true }).messages.map( - (message) => message.role, - ), - ["user", "assistant", "toolResult", "user"], - ); -}); - test("WebUI transcript strips leaked DSML tool call markup from text and thinking", () => { const dsml = [ "<||DSML|| tool_calls>", @@ -737,8 +265,7 @@ test("WebUI transcript strips leaked DSML tool call markup from text and thinkin ], }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const round = assistant.rounds[0]; const allText = JSON.stringify(round.blocks); @@ -784,8 +311,7 @@ test("WebUI transcript hides provider-native web_search tool traces when hosted }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const round = assistant.rounds[0]; assert.equal(round.blocks.some((block) => block.kind === "tool"), false); @@ -793,8 +319,8 @@ test("WebUI transcript hides provider-native web_search tool traces when hosted }); test("WebUI live transcript removes provider-native web_search when hosted search arrives later", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + turn = applyEventToTurn(turn, { type: "tool_call", id: "call_00_webui_search", name: "web_search", @@ -802,10 +328,10 @@ test("WebUI live transcript removes provider-native web_search when hosted searc round: 1, }); - let assistant = chatUi.buildTranscriptItems(entries).find((entry) => entry.kind === "assistant"); + let assistant = findAssistantRow(buildRowsFromEntries(turn.entries, "stream")); assert.equal(assistant.rounds[0].blocks.some((block) => block.kind === "tool"), true); - entries = chatUi.pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "hosted-search-live", provider: "claude_code", @@ -815,7 +341,7 @@ test("WebUI live transcript removes provider-native web_search when hosted searc round: 1, }); - assistant = chatUi.buildTranscriptItems(entries).find((entry) => entry.kind === "assistant"); + assistant = findAssistantRow(buildRowsFromEntries(turn.entries, "stream")); const round = assistant.rounds[0]; assert.equal(round.blocks.some((block) => block.kind === "tool"), false); @@ -824,53 +350,54 @@ test("WebUI live transcript removes provider-native web_search when hosted searc }); test("WebUI live transcript hides recovered provider-native web_search results without hosted search", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "call_00_webui_recovered_search", - name: "WebSearch", - arguments: { query: "LiveAgent recovered search" }, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "call_00_webui_recovered_search", - name: "WebSearch", - content: [{ type: "text", text: "Recovered provider-native web search." }], - details: { recoveredProviderNativeWebSearch: true }, - isError: false, - round: 1, - }); - - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); - const round = assistant.rounds[0]; + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "call_00_webui_recovered_search", + name: "WebSearch", + arguments: { query: "LiveAgent recovered search" }, + round: 1, + }, + { + type: "tool_result", + id: "call_00_webui_recovered_search", + name: "WebSearch", + content: [{ type: "text", text: "Recovered provider-native web search." }], + details: { recoveredProviderNativeWebSearch: true }, + isError: false, + round: 1, + }, + ]); - assert.equal(round.blocks.some((block) => block.kind === "tool"), false); - assert.deepEqual(round.runningToolCallIds, []); + // The recovered result hides the whole trace; with nothing else in the + // round the content gate drops it entirely — no avatar-only assistant row + // (stronger than the old "row without tool blocks" rendering). + const rows = buildRowsFromEntries(turn.entries, "stream"); + assert.equal(findAssistantRow(rows), undefined, "fully hidden trace renders no assistant row"); + assert.equal(rows.length, 0); }); test("WebUI live transcript hides recovered DSML provider-native web_search calls immediately", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "dsml-tool-call-webui-live-search", - name: "builtin_web_search", - arguments: { query: "LiveAgent DSML hidden search" }, - round: 1, - }); - - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); - const round = assistant.rounds[0]; + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "dsml-tool-call-webui-live-search", + name: "builtin_web_search", + arguments: { query: "LiveAgent DSML hidden search" }, + round: 1, + }, + ]); - assert.equal(round.blocks.some((block) => block.kind === "tool"), false); - assert.deepEqual(round.runningToolCallIds, []); + // The DSML-recovered call is hidden immediately; the content-less round is + // dropped so no assistant row (avatar) can appear for it. + const rows = buildRowsFromEntries(turn.entries, "stream"); + assert.equal(findAssistantRow(rows), undefined, "fully hidden call renders no assistant row"); + assert.equal(rows.length, 0); }); -test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes compaction checkpoints", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { +test("turn reducer appends streaming text, dedupes tool cards, and dedupes compaction checkpoints", () => { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + turn = applyEventToTurn(turn, { type: "token", text: "hello ", round: 1, @@ -878,16 +405,16 @@ test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes comp model: "gpt-test", usage: { totalTokens: 12 }, }); - entries = chatUi.pushChatEvent(entries, { type: "token", text: "world", round: 1 }); - assert.equal(entries.length, 1); - assert.equal(entries[0].kind, "assistant"); - assert.equal(entries[0].text, "hello world"); - assert.equal(entries[0].meta.usageTotalTokens, 12); + turn = applyEventToTurn(turn, { type: "token", text: "world", round: 1 }); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].kind, "assistant"); + assert.equal(turn.entries[0].text, "hello world"); + assert.equal(turn.entries[0].meta.usageTotalTokens, 12); const toolCall = { type: "tool_call", id: "call-1", name: "Read", arguments: { path: "README.md" }, round: 1 }; - entries = chatUi.pushChatEvent(entries, toolCall); - entries = chatUi.pushChatEvent(entries, toolCall); - assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); + turn = applyEventToTurn(turn, toolCall); + turn = applyEventToTurn(turn, toolCall); + assert.equal(turn.entries.filter((entry) => entry.kind === "tool_call").length, 1); const checkpoint = { type: "token", @@ -898,53 +425,54 @@ test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes comp generatedBy: { providerId: "liveagent", model: "summary" }, }, }; - entries = chatUi.pushChatEvent(entries, checkpoint); - entries = chatUi.pushChatEvent(entries, checkpoint); - assert.equal(entries.filter((entry) => entry.kind === "checkpoint").length, 1); + turn = applyEventToTurn(turn, checkpoint); + turn = applyEventToTurn(turn, checkpoint); + assert.equal(turn.entries.filter((entry) => entry.kind === "checkpoint").length, 1); }); -test("pushChatEvent preserves tool call arguments from JSON string and input aliases", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-call", - name: "Bash", - arguments: JSON.stringify({ - command: "echo gateway", - cwd: "crates/agent-gateway", - root: "workspace", - }), - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "read-call", - name: "Read", - input: { - path: "README.md", - root: "workspace", +test("turn reducer preserves tool call arguments from JSON string and input aliases", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-call", + name: "Bash", + arguments: JSON.stringify({ + command: "echo gateway", + cwd: "crates/agent-gateway", + root: "workspace", + }), + round: 1, }, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - data: JSON.stringify({ - id: "glob-call", - name: "Glob", - args: { - pattern: "**/*.ts", - path: "src", + { + type: "tool_call", + id: "read-call", + name: "Read", + input: { + path: "README.md", root: "workspace", }, - }), - round: 1, - }); + round: 1, + }, + { + type: "tool_call", + data: JSON.stringify({ + id: "glob-call", + name: "Glob", + args: { + pattern: "**/*.ts", + path: "src", + root: "workspace", + }, + }), + round: 1, + }, + ]); + const entries = turn.entries; const bashCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "bash-call"); const readCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "read-call"); const globCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "glob-call"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlocks = assistant.rounds[0].blocks.filter((block) => block.kind === "tool"); assert.ok(bashCall); @@ -959,29 +487,30 @@ test("pushChatEvent preserves tool call arguments from JSON string and input ali assert.equal(toolBlocks[2].item.toolCall.arguments.pattern, "**/*.ts"); }); -test("pushChatEvent reconstructs a parameterized tool card from tool_result arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-result-only", - name: "Bash", - arguments: { - command: "printf live", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer reconstructs a parameterized tool card from tool_result arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_result", + id: "bash-result-only", + name: "Bash", + arguments: { + command: "printf live", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "live" }], + isError: false, + round: 1, }, - content: [{ type: "text", text: "live" }], - isError: false, - round: 1, - }); + ]); + const entries = turn.entries; assert.equal(entries.length, 2); assert.equal(entries[0].kind, "tool_call"); assert.equal(entries[0].toolCall.arguments.command, "printf live"); assert.equal(entries[1].kind, "tool_result"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -990,36 +519,36 @@ test("pushChatEvent reconstructs a parameterized tool card from tool_result argu assert.equal(toolBlock.item.toolResult.content[0].text, "live"); }); -test("pushChatEvent does not duplicate tool cards when tool_call precedes parameterized tool_result", () => { - let entries = []; +test("turn reducer does not duplicate tool cards when tool_call precedes parameterized tool_result", () => { const toolArguments = { command: "printf once", cwd: "crates/agent-gateway", root: "workspace", }; - - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-no-duplicate", - name: "Bash", - arguments: toolArguments, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-no-duplicate", - name: "Bash", - arguments: toolArguments, - content: [{ type: "text", text: "once" }], - isError: false, - round: 1, - }); + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-no-duplicate", + name: "Bash", + arguments: toolArguments, + round: 1, + }, + { + type: "tool_result", + id: "bash-no-duplicate", + name: "Bash", + arguments: toolArguments, + content: [{ type: "text", text: "once" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlocks = assistant.rounds[0].blocks.filter((block) => block.kind === "tool"); assert.equal(toolBlocks.length, 1); @@ -1027,59 +556,62 @@ test("pushChatEvent does not duplicate tool cards when tool_call precedes parame assert.equal(toolBlocks[0].item.toolResult.content[0].text, "once"); }); -test("pushChatEvent upgrades an existing live tool card when execution start carries arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-late-args", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-late-args", - name: "Bash", - arguments: { - command: "printf from-start", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer upgrades an existing live tool card when execution start carries arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-late-args", + name: "Bash", + round: 1, }, - round: 1, - }); + { + type: "tool_call", + id: "bash-late-args", + name: "Bash", + arguments: { + command: "printf from-start", + cwd: "crates/agent-gateway", + root: "workspace", + }, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries[0].toolCall.arguments.command, "printf from-start"); assert.match(entries[0].summary, /command=printf from-start/); }); -test("pushChatEvent upgrades an existing live tool card when tool_result carries arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-result-args", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-result-args", - name: "Bash", - arguments: { - command: "printf from-result", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer upgrades an existing live tool card when tool_result carries arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-result-args", + name: "Bash", + round: 1, }, - content: [{ type: "text", text: "from-result" }], - isError: false, - round: 1, - }); + { + type: "tool_result", + id: "bash-result-args", + name: "Bash", + arguments: { + command: "printf from-result", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "from-result" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); assert.equal(entries[0].toolCall.arguments.command, "printf from-result"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -1087,252 +619,43 @@ test("pushChatEvent upgrades an existing live tool card when tool_result carries assert.equal(toolBlock.item.toolResult.content[0].text, "from-result"); }); -test("pushChatEvent keeps a deduped result while applying late result arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-duplicate-result", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-duplicate-result", - name: "Bash", - content: [{ type: "text", text: "duplicate" }], - isError: false, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-duplicate-result", - name: "Bash", - arguments: { - command: "printf duplicate", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer keeps a deduped result while applying late result arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-duplicate-result", + name: "Bash", + round: 1, }, - content: [{ type: "text", text: "duplicate" }], - isError: false, - round: 1, - }); + { + type: "tool_result", + id: "bash-duplicate-result", + name: "Bash", + content: [{ type: "text", text: "duplicate" }], + isError: false, + round: 1, + }, + { + type: "tool_result", + id: "bash-duplicate-result", + name: "Bash", + arguments: { + command: "printf duplicate", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "duplicate" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); assert.equal(entries[0].toolCall.arguments.command, "printf duplicate"); }); -test("createLiveConversationStreamStore batches entries and clears tool status on terminal events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - let notifications = 0; - const unsubscribe = store.subscribe(() => { - notifications += 1; - }); - - store.setToolStatus("Compacting...", true); - assert.equal(store.getSnapshot().toolStatus, "Compacting..."); - assert.equal(store.getSnapshot().toolStatusIsCompaction, true); - assert.equal(notifications, 1); - - store.appendEvent({ type: "token", text: "hello", round: 1 }); - assert.equal(store.getSnapshot().entries[0].text, "hello"); - assert.equal(store.getSnapshot().toolStatus, "Compacting..."); - - store.appendEvent({ type: "done", conversation_id: "conversation-1" }); - assert.equal(store.getSnapshot().toolStatus, null); - assert.equal(store.getSnapshot().toolStatusIsCompaction, false); - - unsubscribe(); -}); - -test("createLiveConversationStreamStore commits background events without waiting for animation frames", () => { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - let rafScheduled = 0; - globalThis.window = { - requestAnimationFrame() { - rafScheduled += 1; - return 1; - }, - cancelAnimationFrame() {}, - setTimeout(callback) { - callback(); - return 1; - }, - clearTimeout() {}, - }; - globalThis.document = { visibilityState: "hidden" }; - - try { - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ type: "token", text: "background", round: 1 }); - - assert.equal(rafScheduled, 0); - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "background"); - } finally { - globalThis.window = previousWindow; - globalThis.document = previousDocument; - } -}); - -test("createLiveConversationStreamStore falls back when a scheduled animation frame is paused", () => { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - let fallbackCallback = null; - let canceledFrame = null; - globalThis.window = { - requestAnimationFrame() { - return 7; - }, - cancelAnimationFrame(id) { - canceledFrame = id; - }, - setTimeout(callback) { - fallbackCallback = callback; - return 11; - }, - clearTimeout() {}, - }; - globalThis.document = { visibilityState: "visible" }; - - try { - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ type: "token", text: "queued", round: 1 }); - - assert.equal(store.getSnapshot().entries.length, 0); - assert.equal(typeof fallbackCallback, "function"); - - fallbackCallback(); - - assert.equal(canceledFrame, 7); - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "queued"); - } finally { - globalThis.window = previousWindow; - globalThis.document = previousDocument; - } -}); - -test("createLiveConversationStreamStore ignores replayed events with the same seq", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "token", - text: "hello", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: "hello", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: " world", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "hello world"); - - store.reset(); - store.appendEvent({ - type: "token", - text: "fresh", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "fresh"); -}); - -test("createLiveConversationStreamStore does not render queue control events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "accepted", - state: "queued", - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "queued_in_gui", - state: "desktop_queued", - conversation_id: "conversation-1", - seq: 2, - }); - store.appendEvent({ - type: "started", - state: "running", - conversation_id: "conversation-1", - seq: 3, - }); - store.appendEvent({ - type: "token", - text: "visible", - round: 1, - conversation_id: "conversation-1", - seq: 4, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "visible"); -}); - -test("createLiveConversationStreamStore renders live user_message events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "user_message", - message: "queued from gui", - uploaded_files: [ - { - relative_path: "notes.md", - absolute_path: "/workspace/notes.md", - file_name: "notes.md", - kind: "text", - size_bytes: 12, - }, - ], - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: "reply", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }); - - const entries = store.getSnapshot().entries; - assert.equal(entries.length, 2); - assert.equal(entries[0].kind, "user"); - assert.equal(entries[0].text, "queued from gui"); - assert.equal(entries[0].attachments.length, 1); - assert.equal(entries[0].attachments[0].relativePath, "notes.md"); - assert.equal(entries[1].kind, "assistant"); - assert.equal(entries[1].text, "reply"); -}); - function findTreeNode(node, predicate) { if (Array.isArray(node)) { for (const child of node) { @@ -1360,7 +683,56 @@ function findTreeNode(node, predicate) { return null; } -test("GatewayTranscript live renderer shows user bubbles before live assistant output", () => { +test("formatConversationTitle falls back to stable labels", () => { + assert.equal(chatUi.formatConversationTitle({ id: "abc", title: " Named " }), "Named"); + assert.equal(chatUi.formatConversationTitle(null, "conversation-abcdef"), "会话 conversa"); + assert.equal(chatUi.formatConversationTitle(null, ""), "新对话"); +}); + +test("resolveConversationBrowserTitle uses project title for project-level empty selection", () => { + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: null, + conversationId: "conversation-abcdef", + projectName: " Project Alpha ", + newConversationTitle: "LiveAgent", + }), + "Project Alpha", + ); + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: { id: "conversation-abcdef", title: " Named " }, + conversationId: "conversation-abcdef", + projectName: "Project Alpha", + newConversationTitle: "LiveAgent", + }), + "Named", + ); + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: null, + conversationId: "__local_draft__:abc", + projectName: "Project Alpha", + isLocalDraftConversation: true, + newConversationTitle: "LiveAgent", + }), + "LiveAgent", + ); +}); + +test("buildOptimisticConversationTitle uses the first ten characters of the first prompt paragraph", () => { + assert.equal( + chatUi.buildOptimisticConversationTitle(" 12345 67890 abc\nstill first paragraph\n\nsecond"), + "12345 6789", + ); + assert.equal( + chatUi.buildOptimisticConversationTitle("这是第一段提示词超过十个字\n\n第二段"), + "这是第一段提示词超过", + ); + assert.equal(chatUi.buildOptimisticConversationTitle(" \n\n "), "新对话"); +}); + +test("GatewayTranscript renders folded and live regions as slices of one row list", () => { const fakeReact = { createContext(defaultValue) { return { defaultValue }; @@ -1428,47 +800,89 @@ test("GatewayTranscript live renderer shows user bubbles before live assistant o }, }, }); - const transcriptLiveStore = transcriptLoader.loadModule("src/lib/liveConversationStreamStore.ts"); const { GatewayTranscript } = transcriptLoader.loadModule("src/components/GatewayTranscript.tsx"); globalThis.window = undefined; globalThis.document = { visibilityState: "visible" }; - const store = transcriptLiveStore.createLiveConversationStreamStore(); - store.appendEvent( - { - type: "user_message", - message: "queued from gui", - conversation_id: "conversation-1", - seq: 1, - }, - { flush: true }, + // Folded rows come from parsed history; live rows are born from the + // stream (a seeded prompt plus its streaming reply). Both regions come + // from one store assembly: foldedRows + liveRows. + const store = transcriptStoreModule.createTranscriptStore(); + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "earlier question", attachments: [] }, + { id: "ht:hu:m1>0", kind: "assistant", text: "earlier answer", round: 1 }, + ], + { mode: "replace" }, ); - store.appendEvent( - { - type: "token", - text: "reply", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }, - { flush: true }, + store.applyEvent({ + type: "user_message", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 1, + message: "queued from gui", + }); + store.applyEvent({ + type: "run_started", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 2, + }); + store.applyEvent({ + type: "token", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 3, + text: "reply", + }); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.foldedRows.length, 2, "history rows fold; the live exchange stays below"); + assert.deepEqual( + [...snapshot.foldedRows, ...snapshot.liveRows].map((row) => row.kind), + ["user", "assistant", "user", "assistant"], ); const transcriptTree = GatewayTranscript({ conversationId: "conversation-1", - entries: [], - liveStore: store, - hasLiveStream: true, + foldedRows: snapshot.foldedRows, + liveRows: snapshot.liveRows, + activeTurnKey: snapshot.activeTurnKey, isStreaming: true, }); - const liveStateNode = findTreeNode( + + const foldedRegionNode = findTreeNode( transcriptTree, - (node) => typeof node.type === "function" && node.props?.liveSnapshot, + (node) => + typeof node.type === "function" && + Array.isArray(node.props?.rows) && + node.props?.conversationId === "conversation-1", + ); + assert.ok(foldedRegionNode, "virtualized region receives the folded rows"); + assert.deepEqual( + foldedRegionNode.props.rows.map((row) => row.key), + snapshot.foldedRows.map((row) => row.key), ); - assert.ok(liveStateNode); - const liveTree = liveStateNode.type(liveStateNode.props); + const liveRegionNode = findTreeNode( + transcriptTree, + (node) => + typeof node.type === "function" && + Array.isArray(node.props?.rows) && + node.props?.isAgentMode !== undefined, + ); + assert.ok(liveRegionNode, "live region receives the rows past the fold boundary"); + assert.deepEqual( + liveRegionNode.props.rows.map((row) => row.key), + snapshot.liveRows.map((row) => row.key), + ); + assert.deepEqual( + liveRegionNode.props.rows.map((row) => row.kind), + ["user", "assistant"], + ); + + const liveTree = liveRegionNode.type(liveRegionNode.props); assert.ok( findTreeNode( liveTree, @@ -1476,403 +890,75 @@ test("GatewayTranscript live renderer shows user bubbles before live assistant o typeof node.props?.className === "string" && node.props.className.includes("gateway-transcript-row-user"), ), + "live user bubble renders before the live assistant output", ); assert.ok( findTreeNode( liveTree, - (node) => typeof node.type === "function" && node.props?.text === "queued from gui", + // User rows render through GatewayUserMessageRowBody, which receives + // the whole row (row.text) rather than a bare text prop. + (node) => + typeof node.type === "function" && + (node.props?.text === "queued from gui" || node.props?.row?.text === "queued from gui"), ), ); -}); - -test("mergeHistorySnapshotEntries appends remote user turn without dropping loaded history", () => { - const existing = [ - { id: "existing-user-1", kind: "user", text: "first", attachments: [] }, - { id: "existing-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - ]; - const incoming = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "history-user-2", kind: "user", text: "second prompt", attachments: [] }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["first", "first answer", "second prompt"], - ); - assert.equal(merged[0].id, "history-user-1"); - assert.equal(merged[2].id, "history-user-2"); -}); - -test("mergeHistorySnapshotEntries keeps full-history prefix when incoming snapshot is a suffix", () => { - const existing = [ - { id: "old-user", kind: "user", text: "old", attachments: [] }, - { id: "old-assistant", kind: "assistant", text: "old answer", round: 1 }, - { id: "recent-user", kind: "user", text: "recent", attachments: [] }, - { id: "recent-assistant", kind: "assistant", text: "recent answer", round: 1 }, - ]; - const incoming = [ - { id: "snapshot-user", kind: "user", text: "recent", attachments: [] }, - { id: "snapshot-assistant", kind: "assistant", text: "recent answer", round: 1 }, - { id: "snapshot-new-user", kind: "user", text: "new prompt", attachments: [] }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["old", "old answer", "recent", "recent answer", "new prompt"], - ); - assert.equal(merged[0].id, "old-user"); - assert.equal(merged[2].id, "snapshot-user"); -}); - -test("mergeHistorySnapshotEntries replaces assistant-only live snapshot with persisted user turn", () => { - const existing = [ - { id: "committed-live-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - const incoming = [ - { id: "history-user", kind: "user", text: "remote prompt", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["remote prompt", "live answer"], - ); - assert.equal(merged[0].id, "history-user"); - assert.equal(merged[1].id, "history-assistant"); -}); - -test("mergeHistorySnapshotEntries keeps live transcript stable when only assistant metadata moves", () => { - const meta = { - provider: "anthropic", - model: "claude-opus", - usage: { input: 1, output: 2, totalTokens: 3 }, - }; - const existing = [ - { id: "live-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "live-assistant-prefix", kind: "assistant", text: "\n", round: 1 }, - { id: "live-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "live-assistant-answer", kind: "assistant", text: "你好!", round: 1, meta }, - ]; - const incoming = [ - { id: "history-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "history-assistant-prefix", kind: "assistant", text: "\n", round: 1, meta }, - { id: "history-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "history-assistant-answer", kind: "assistant", text: "你好!", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.equal(merged, existing); - assert.deepEqual( - merged.map((entry) => entry.id), - ["live-user-1", "live-assistant-prefix", "live-thinking", "live-assistant-answer"], - ); -}); - -test("omitEquivalentTailEntries hides committed live tail without dropping history", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "committed-live-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - const liveEntries = [ - { id: "live-assistant-1", kind: "assistant", text: "live answer", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["first", "first answer"], + const assistantBubble = findTreeNode( + liveTree, + (node) => typeof node.type === "function" && node.props?.renderMode !== undefined, ); - assert.equal(visibleHistory[1].id, "history-assistant-1"); -}); - -test("omitEquivalentTailEntries treats assistant metadata placement as the same visible tail", () => { - const meta = { - provider: "anthropic", - model: "claude-opus", - usage: { input: 1, output: 2, totalTokens: 3 }, - }; - const existing = [ - { id: "history-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "history-assistant-prefix", kind: "assistant", text: "\n", round: 1, meta }, - { id: "history-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "history-assistant-answer", kind: "assistant", text: "你好!", round: 1 }, - ]; - const liveEntries = [ - { id: "live-assistant-prefix", kind: "assistant", text: "\n", round: 1 }, - { id: "live-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "live-assistant-answer", kind: "assistant", text: "你好!", round: 1, meta }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["你好"], - ); -}); - -test("omitEquivalentTailEntries removes live user and assistant overlap", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "history-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "history-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - const liveEntries = [ - { id: "live-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "live-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["first", "first answer"], - ); -}); - -test("omitEquivalentTailEntries removes uploaded live user overlap", () => { - const historyAttachment = { - relativePath: ".liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - }; - const liveAttachment = { - relativePath: ".liveagent/uploads/08-conclusion.png", - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - }; - const existing = [ - { id: "history-user-1", kind: "user", text: "这是什么", attachments: [historyAttachment] }, - { id: "history-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - const liveEntries = [ - { id: "live-user-1", kind: "user", text: "这是什么", attachments: [liveAttachment] }, - { id: "live-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual(visibleHistory, []); -}); - -test("appendCommittedLiveEntries does not duplicate optimistic user message overlaps", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "optimistic-user-2", kind: "user", text: "queued from gui", attachments: [] }, - ]; - const liveEntries = [ - { id: "live-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "live-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - - const merged = liveCommit.appendCommittedLiveEntries(existing, liveEntries); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["first", "queued from gui", "reply"], - ); - assert.equal(merged[1].id, "optimistic-user-2"); - assert.equal(merged[2].kind, "assistant"); -}); - -test("appendCommittedLiveEntries does not duplicate uploaded optimistic user overlaps", () => { - const existing = [ - { - id: "optimistic-user-1", - kind: "user", - text: "这是什么", - attachments: [ - { - relativePath: ".liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - }, - ], - }, - ]; - const liveEntries = [ - { - id: "live-user-1", - kind: "user", - text: "这是什么", - attachments: [ - { - relativePath: ".liveagent/uploads/08-conclusion.png", - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - }, - ], - }, - { id: "live-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - - const merged = liveCommit.appendCommittedLiveEntries(existing, liveEntries); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["这是什么", "reply"], - ); - assert.equal(merged[0].id, "optimistic-user-1"); -}); - -test("mergeHistorySnapshotEntries replaces stale local entries when an authoritative snapshot is shorter", () => { - // Simulates: peer A edits the user prompt and resends, server-side history - // is now just the new user turn. Without `isFullSnapshot`, the local two-turn - // tail is kept and collides with the incoming live stream, producing the - // duplicated assistant bubble. With the flag set we yield to the server. - const existing = [ - { id: "local-user-1", kind: "user", text: "old prompt", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "old answer", round: 1 }, - ]; - const incoming = [ - { id: "server-user-1", kind: "user", text: "edited prompt", attachments: [] }, - ]; - - const withoutFlag = liveCommit.mergeHistorySnapshotEntries(existing, incoming); + assert.ok(assistantBubble, "live assistant bubble rendered"); assert.equal( - withoutFlag, - existing, - "without the hint we conservatively keep existing — the bug being fixed", - ); - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming, { - isFullSnapshot: true, - }); - assert.deepEqual( - merged.map((entry) => entry.text), - ["edited prompt"], + assistantBubble.props.renderMode, + "streaming", + "live-born rows keep the streaming render mode", ); - assert.notStrictEqual(merged, existing); }); -test("mergeHistorySnapshotEntries with isFullSnapshot=true clears entries when the server is empty", () => { - const existing = [ - { id: "local-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - +test("transcript store history refresh stays quiet for identical content", () => { + // The old pipeline kept a quiet refresh stable via text-hash dedup keys + // (renamed incoming ids were re-mapped onto the rendered ones). The new + // parser makes ids deterministic instead: reparsing the same persisted + // JSON yields identical ids, so an idle "enrich" refresh with unchanged + // content is a structural no-op — same snapshot, same row keys, and the + // exchange still renders exactly once. + globalThis.window = undefined; + globalThis.document = { visibilityState: "visible" }; + const store = transcriptStoreModule.createTranscriptStore(); + + const messagesJson = JSON.stringify([ + { role: "user", content: "hello" }, + { role: "assistant", content: "world" }, + ]); + const firstParse = chatUi.parseHistoryMessagesJson(messagesJson); + store.applyHistorySnapshot(firstParse, { mode: "replace" }); + store.flush(); + const first = store.getSnapshot(); assert.deepEqual( - liveCommit.mergeHistorySnapshotEntries(existing, [], { isFullSnapshot: true }), - [], + first.foldedRows.map((row) => row.kind), + ["user", "assistant"], ); - // Without the hint, empty incoming is treated as "no update available" and - // existing is preserved (used during paginated tail fetches). - assert.equal(liveCommit.mergeHistorySnapshotEntries(existing, []), existing); -}); - -test("mergeHistorySnapshotEntries keeps existing user ids when only the server-side messageRef appears", () => { - // Locally created user entries do not have `messageRef` yet — the gateway - // assigns it when persisting. The post-stream history refresh would - // otherwise see "different" entries and replace them, changing every user - // article's React key and re-firing the `chat-bubble-enter` animation on - // every user bubble at once. - const existing = [ - { id: "local-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "live-assistant-1", kind: "assistant", text: "回复", round: 1 }, - ]; - const incoming = [ - { - id: "server-user-1", - kind: "user", - text: "你好", - attachments: [], - messageRef: { segmentIndex: 0, messageIndex: 0 }, - }, - { id: "server-assistant-1", kind: "assistant", text: "回复", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); + assert.equal(first.liveRows.length, 0, "history renders in the folded region"); + const secondParse = chatUi.parseHistoryMessagesJson(messagesJson); assert.deepEqual( - merged.map((entry) => entry.id), - ["local-user-1", "live-assistant-1"], - "existing ids must be preserved so React keys stay stable", + secondParse.map((entry) => entry.id), + firstParse.map((entry) => entry.id), + "reparsing the same JSON yields identical deterministic ids", ); - assert.deepEqual(merged[0].messageRef, { segmentIndex: 0, messageIndex: 0 }); -}); -test("mergeHistorySnapshotEntries with isFullSnapshot=true still preserves identity for matching snapshots", () => { - const existing = [ - { id: "local-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - const incoming = [ - { id: "server-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "server-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming, { - isFullSnapshot: true, - }); - // Visually equivalent → keep existing references so the React tree does not - // remount and the article keys stay stable. - assert.equal(merged, existing); -}); - -test("formatConversationTitle falls back to stable labels", () => { - assert.equal(chatUi.formatConversationTitle({ id: "abc", title: " Named " }), "Named"); - assert.equal(chatUi.formatConversationTitle(null, "conversation-abcdef"), "会话 conversa"); - assert.equal(chatUi.formatConversationTitle(null, ""), "新对话"); -}); - -test("resolveConversationBrowserTitle uses project title for project-level empty selection", () => { - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: null, - conversationId: "conversation-abcdef", - projectName: " Project Alpha ", - newConversationTitle: "LiveAgent", - }), - "Project Alpha", - ); - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: { id: "conversation-abcdef", title: " Named " }, - conversationId: "conversation-abcdef", - projectName: "Project Alpha", - newConversationTitle: "LiveAgent", - }), - "Named", - ); - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: null, - conversationId: "__local_draft__:abc", - projectName: "Project Alpha", - isLocalDraftConversation: true, - newConversationTitle: "LiveAgent", - }), - "LiveAgent", - ); -}); - -test("buildOptimisticConversationTitle uses the first ten characters of the first prompt paragraph", () => { - assert.equal( - chatUi.buildOptimisticConversationTitle(" 12345 67890 abc\nstill first paragraph\n\nsecond"), - "12345 6789", + // Idle quiet refresh with identical content: nothing re-renders. + store.applyHistorySnapshot(secondParse, { mode: "enrich" }); + store.flush(); + const second = store.getSnapshot(); + assert.equal(second, first, "identical content leaves the snapshot untouched"); + assert.deepEqual( + second.foldedRows.map((row) => row.key), + first.foldedRows.map((row) => row.key), + "row keys are stable across the refresh", ); assert.equal( - chatUi.buildOptimisticConversationTitle("这是第一段提示词超过十个字\n\n第二段"), - "这是第一段提示词超过", + [...second.foldedRows, ...second.liveRows].filter((row) => row.kind === "user").length, + 1, + "the exchange renders exactly once (no duplicate prompt)", ); - assert.equal(chatUi.buildOptimisticConversationTitle(" \n\n "), "新对话"); }); diff --git a/crates/agent-gateway/test/webui/terminal-session-store.test.mjs b/crates/agent-gateway/test/webui/terminal-session-store.test.mjs index 91d8605c5..9f34048d0 100644 --- a/crates/agent-gateway/test/webui/terminal-session-store.test.mjs +++ b/crates/agent-gateway/test/webui/terminal-session-store.test.mjs @@ -92,3 +92,112 @@ test("terminal project matching normalizes Windows-shaped project keys", () => { false, ); }); + +// --- XTermViewport chunk bookkeeping (gap / reset handling) --- +// These cases exercise the viewport's writeTerminalChunk rather than the +// session store above: the reconnect-gap "reset & replay" contract lives in +// the viewport, and this is the terminal-focused suite that loads web modules. + +const viewportLoader = createWebModuleLoader({ + mocks: { + "@xterm/xterm/css/xterm.css": {}, + "@xterm/xterm": { Terminal: class Terminal {} }, + "@xterm/addon-fit": { FitAddon: class FitAddon {} }, + }, +}); +const { writeTerminalChunk } = viewportLoader.loadModule( + "@/components/project-tools/XTermViewport.tsx", +); + +function fakeTerm() { + const calls = []; + return { + calls, + write(data) { + calls.push(["write", Uint8Array.from(data)]); + }, + reset() { + calls.push(["reset"]); + }, + }; +} + +function chunk(bytes, startOffset, endOffset) { + return { + sessionId: "terminal-1", + projectPathKey: "/workspace/project", + bytes: Uint8Array.from(bytes), + startOffset, + endOffset, + }; +} + +test("terminal chunk overlapping the rendered offset is trimmed before writing", () => { + const term = fakeTerm(); + let offset = 10; + const result = writeTerminalChunk( + term, + chunk([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5, 15), + (next) => { + offset = next; + }, + offset, + ); + assert.equal(result, "written"); + assert.equal(offset, 15); + assert.deepEqual(term.calls, [["write", Uint8Array.from([6, 7, 8, 9, 10])]]); +}); + +test("terminal chunk entirely behind the rendered offset is skipped", () => { + const term = fakeTerm(); + let offset = 20; + const result = writeTerminalChunk( + term, + chunk([1, 2, 3], 10, 13), + (next) => { + offset = next; + }, + offset, + ); + assert.equal(result, "skipped"); + assert.equal(offset, 20); + assert.deepEqual(term.calls, []); +}); + +test("terminal chunk after a gap resets the terminal and replays the chunk", () => { + const term = fakeTerm(); + let offset = 10; + const result = writeTerminalChunk( + term, + chunk([7, 8, 9], 20, 23), + (next) => { + offset = next; + }, + offset, + ); + assert.equal(result, "reset"); + assert.equal(offset, 23); + assert.deepEqual(term.calls, [["reset"], ["write", Uint8Array.from([7, 8, 9])]]); +}); + +test("terminal chunk without offsets appends and advances by byte length", () => { + const term = fakeTerm(); + let offset = 4; + const result = writeTerminalChunk( + term, + { + sessionId: "terminal-1", + projectPathKey: "/workspace/project", + bytes: Uint8Array.from([1, 2]), + startOffset: undefined, + endOffset: undefined, + }, + (next) => { + offset = next; + }, + offset, + ); + assert.equal(result, "written"); + assert.equal(offset, 6); + assert.deepEqual(term.calls, [["write", Uint8Array.from([1, 2])]]); +}); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 328961579..a05a7a3ac 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -101,29 +101,24 @@ test("web settings normalization canonicalizes project keyed maps with Windows p assert.deepEqual(normalized.customSettings.rightDock.projects["c:/repo"], { activeTabId: RIGHT_DOCK_TAB_IDS.fileTree, tabOrder: [RIGHT_DOCK_TAB_IDS.gitReview, RIGHT_DOCK_TAB_IDS.fileTree], - tabs: { - [RIGHT_DOCK_TAB_IDS.fileTree]: { - id: RIGHT_DOCK_TAB_IDS.fileTree, - kind: "fileTree", - projectPathKey: "c:/repo", - createdAt: 1, + tools: { + fileTree: { + openedAt: 1, uiState: { query: "legacy", selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/components"], revision: 2, - stateVersion: 0, }, }, - [RIGHT_DOCK_TAB_IDS.gitReview]: { - id: RIGHT_DOCK_TAB_IDS.gitReview, - kind: "gitReview", - projectPathKey: "c:/repo", - createdAt: 2, + gitReview: { + openedAt: 2, }, }, openVersion: 0, stateVersion: 0, + writerId: "", + lastUsedAt: 0, }); }); @@ -1092,7 +1087,6 @@ test("gateway settings sync keeps right dock width local and syncs project state selectedPath: "desktop.ts", expandedPaths: ["", "src"], revision: 1, - stateVersion: 3, }, ); assert.equal(synced.customSettings.rightDock.projects["/shared/project"].openVersion, 5); @@ -1164,34 +1158,6 @@ test("web remote settings normalize single-slash http gateway URLs", () => { assert.equal(remoteWithOversizedPort.grpcPort, 65_535); }); -test("web cron task normalization preserves finite and exhausted run counts", () => { - const finite = settings.normalizeCronTask({ - id: "cron-finite", - type: "bash", - script: "echo finite", - remainingExecutions: "2", - }); - assert.equal(finite.remainingExecutions, 2); - - const exhausted = settings.normalizeCronTask({ - id: "cron-exhausted", - type: "bash", - script: "echo exhausted", - enabled: true, - remainingExecutions: 0, - }); - assert.equal(exhausted.remainingExecutions, 0); - assert.equal(exhausted.enabled, false); - - const invalid = settings.normalizeCronTask({ - id: "cron-invalid", - type: "bash", - script: "echo invalid", - remainingExecutions: "-1", - }); - assert.equal(invalid.remainingExecutions, undefined); -}); - test("web provider normalization keeps native web search toggle", () => { const enabledByDefault = settings.normalizeCustomProvider({ id: "provider-enabled", @@ -1208,3 +1174,101 @@ test("web provider normalization keeps native web search toggle", () => { }); assert.equal(disabled.nativeWebSearchEnabled, false); }); + +test("web right dock normalize keeps unknown session ids and unresolved active tab", () => { + const project = settings.normalizeRightDockProjectState({ + activeTabId: "sess-active", + tabOrder: ["sess-a", RIGHT_DOCK_TAB_IDS.gitReview, "sess-b"], + tools: { gitReview: { openedAt: 4 } }, + openVersion: 1, + stateVersion: 2, + writerId: "peer", + lastUsedAt: 9, + }); + assert.deepEqual(project.tabOrder, ["sess-a", RIGHT_DOCK_TAB_IDS.gitReview, "sess-b"]); + assert.equal(project.activeTabId, "sess-active"); + assert.deepEqual(Object.keys(project.tools), ["gitReview"]); +}); + +test("web right dock merge converges symmetrically on writerId ties", () => { + const bucket = (writerId, activeTabId) => ({ + activeTabId, + tabOrder: [activeTabId], + tools: { gitReview: { openedAt: 1 } }, + openVersion: 1, + stateVersion: 3, + writerId, + lastUsedAt: 100, + }); + const stateA = settings.normalizeSettings({ + customSettings: { rightDock: { projects: { "/w/app": bucket("bbb", RIGHT_DOCK_TAB_IDS.gitReview) } } }, + }); + const stateB = settings.normalizeSettings({ + customSettings: { rightDock: { projects: { "/w/app": bucket("aaa", "sess-2") } } }, + }); + const aGotB = settingsSync.applyGatewaySettingsSyncPayload(stateA, { + customSettings: { rightDock: stateB.customSettings.rightDock }, + }); + const bGotA = settingsSync.applyGatewaySettingsSyncPayload(stateB, { + customSettings: { rightDock: stateA.customSettings.rightDock }, + }); + const mergedA = aGotB.customSettings.rightDock.projects["/w/app"]; + const mergedB = bGotA.customSettings.rightDock.projects["/w/app"]; + assert.equal(mergedA.activeTabId, RIGHT_DOCK_TAB_IDS.gitReview); + assert.deepEqual(mergedA, mergedB); + assert.equal(mergedA.stateVersion, 3); + assert.equal(mergedA.lastUsedAt, 100); +}); + +test("web right dock buckets are kept by recency and tombstones expire", () => { + const now = Date.now(); + const projects = {}; + for (let index = 0; index <= 100; index += 1) { + projects[`/p/n${String(index).padStart(3, "0")}`] = { + tabOrder: [], + tools: { gitReview: { openedAt: 1 } }, + openVersion: 1, + stateVersion: 1, + writerId: "w", + lastUsedAt: now - index * 1000, + }; + } + const capped = settings.normalizeRightDockSettings({ projects }); + assert.equal(Object.keys(capped.projects).length, 100); + assert.equal(capped.projects["/p/n100"], undefined); + assert.ok(capped.projects["/p/n000"]); + + const tombstones = settings.normalizeRightDockSettings({ + projects: { + "/t/expired": { tools: {}, openVersion: 1, stateVersion: 2, lastUsedAt: now - 91 * 24 * 3600 * 1000 }, + "/t/fresh": { tools: {}, openVersion: 1, stateVersion: 2, lastUsedAt: now - 1000 }, + "/t/legacy": { tools: {}, openVersion: 1, stateVersion: 2 }, + }, + }); + assert.deepEqual(Object.keys(tombstones.projects).sort(), ["/t/fresh", "/t/legacy"]); + assert.ok(tombstones.projects["/t/legacy"].lastUsedAt >= now - 1000); +}); + +test("web right dock migrates the legacy tabs shape", () => { + const project = settings.normalizeRightDockProjectState({ + activeTabId: "sess-1", + tabOrder: ["sess-1", RIGHT_DOCK_TAB_IDS.fileTree], + tabs: { + "sess-1": { id: "sess-1", kind: "terminal", projectPathKey: "/w/app", createdAt: 1 }, + [RIGHT_DOCK_TAB_IDS.fileTree]: { + id: RIGHT_DOCK_TAB_IDS.fileTree, + kind: "fileTree", + projectPathKey: "/w/app", + createdAt: 7, + uiState: { query: "q", expandedPaths: ["", "src"] }, + }, + }, + openVersion: 2, + stateVersion: 5, + }); + assert.deepEqual(Object.keys(project.tools), ["fileTree"]); + assert.equal(project.tools.fileTree.openedAt, 7); + assert.equal(project.tools.fileTree.uiState.query, "q"); + assert.deepEqual(project.tabOrder, ["sess-1", RIGHT_DOCK_TAB_IDS.fileTree]); + assert.equal(project.activeTabId, "sess-1"); +}); diff --git a/crates/agent-gateway/web/biome.json b/crates/agent-gateway/web/biome.json new file mode 100644 index 000000000..6c1ea5e54 --- /dev/null +++ b/crates/agent-gateway/web/biome.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "includes": [ + "src/**", + "!!**/dist", + "!!**/node_modules" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "useKeyWithClickEvents": "warn", + "noStaticElementInteractions": "warn", + "noLabelWithoutControl": "warn", + "useSemanticElements": "warn", + "useAriaPropsSupportedByRole": "warn", + "useAriaPropsForRole": "warn", + "useFocusableInteractive": "warn", + "noAutofocus": "off", + "noSvgWithoutTitle": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn", + "noUnusedImports": "error" + }, + "suspicious": { + "noArrayIndexKey": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": [ + "**/*.css" + ], + "linter": { + "rules": { + "suspicious": { + "noDuplicateProperties": "off" + } + } + } + } + ] +} diff --git a/crates/agent-gateway/web/package.json b/crates/agent-gateway/web/package.json index 5c216c3bb..bfb18f6ea 100644 --- a/crates/agent-gateway/web/package.json +++ b/crates/agent-gateway/web/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "biome check src/", + "format": "biome format --write src/", + "lint:fix": "biome check --write src/" }, "dependencies": { "@git-diff-view/file": "^0.1.3", @@ -39,6 +42,7 @@ "yet-another-react-lightbox": "^3.31.0" }, "devDependencies": { + "@biomejs/biome": "^2.4.15", "@iconify-json/logos": "^1.2.11", "@iconify-json/lucide": "^1.2.108", "@iconify-json/material-icon-theme": "1.2.67", diff --git a/crates/agent-gateway/web/pnpm-lock.yaml b/crates/agent-gateway/web/pnpm-lock.yaml index 5a0eb8071..911216bae 100644 --- a/crates/agent-gateway/web/pnpm-lock.yaml +++ b/crates/agent-gateway/web/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^3.31.0 version: 3.31.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) devDependencies: + '@biomejs/biome': + specifier: ^2.4.15 + version: 2.5.2 '@iconify-json/logos': specifier: ^1.2.11 version: 1.2.11 @@ -215,6 +218,63 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.2': + resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.2': + resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.2': + resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.2': + resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.2': + resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.2': + resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.2': + resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.2': + resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.2': + resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -2597,6 +2657,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.5.2': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.2 + '@biomejs/cli-darwin-x64': 2.5.2 + '@biomejs/cli-linux-arm64': 2.5.2 + '@biomejs/cli-linux-arm64-musl': 2.5.2 + '@biomejs/cli-linux-x64': 2.5.2 + '@biomejs/cli-linux-x64-musl': 2.5.2 + '@biomejs/cli-win32-arm64': 2.5.2 + '@biomejs/cli-win32-x64': 2.5.2 + + '@biomejs/cli-darwin-arm64@2.5.2': + optional: true + + '@biomejs/cli-darwin-x64@2.5.2': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.2': + optional: true + + '@biomejs/cli-linux-arm64@2.5.2': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.2': + optional: true + + '@biomejs/cli-linux-x64@2.5.2': + optional: true + + '@biomejs/cli-win32-arm64@2.5.2': + optional: true + + '@biomejs/cli-win32-x64@2.5.2': + optional: true + '@braintree/sanitize-url@7.1.2': {} '@chevrotain/cst-dts-gen@12.0.0': diff --git a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx b/crates/agent-gateway/web/src/app/FileDropOverlay.tsx index 358398551..5b1681bec 100644 --- a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx +++ b/crates/agent-gateway/web/src/app/FileDropOverlay.tsx @@ -50,9 +50,7 @@ export function FileDropOverlay({