@pi-in-go/pigpen-ahp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +27 -0
- package/LICENSE +23 -0
- package/README.md +148 -0
- package/extensions/ahp/access_options_test.go +116 -0
- package/extensions/ahp/extension.go +54 -0
- package/extensions/ahp/go.mod +12 -0
- package/extensions/ahp/go.sum +2 -0
- package/extensions/ahp/go.work +8 -0
- package/extensions/ahp/internal/channels/chat.go +110 -0
- package/extensions/ahp/internal/channels/root.go +32 -0
- package/extensions/ahp/internal/channels/session.go +128 -0
- package/extensions/ahp/internal/channels/session_test.go +108 -0
- package/extensions/ahp/internal/compose/compose.go +153 -0
- package/extensions/ahp/internal/compose/compose_test.go +120 -0
- package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
- package/extensions/ahp/internal/host/handshake_test.go +206 -0
- package/extensions/ahp/internal/host/host.go +1305 -0
- package/extensions/ahp/internal/host/reconnect_test.go +197 -0
- package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
- package/extensions/ahp/internal/host/schema_test.go +79 -0
- package/extensions/ahp/internal/host/store.go +291 -0
- package/extensions/ahp/internal/host/store_update_test.go +85 -0
- package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
- package/extensions/ahp/internal/host/surface_test.go +95 -0
- package/extensions/ahp/internal/host/workarounds.go +458 -0
- package/extensions/ahp/internal/host/workarounds_test.go +96 -0
- package/extensions/ahp/internal/live/live.go +328 -0
- package/extensions/ahp/internal/live/live_test.go +91 -0
- package/extensions/ahp/internal/live/store.go +113 -0
- package/extensions/ahp/internal/mapper/activity.go +199 -0
- package/extensions/ahp/internal/mapper/activity_test.go +188 -0
- package/extensions/ahp/internal/mapper/bench_test.go +38 -0
- package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
- package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
- package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
- package/extensions/ahp/internal/mapper/js.go +131 -0
- package/extensions/ahp/internal/mapper/mapper.go +600 -0
- package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
- package/extensions/ahp/internal/mapper/messageinput.go +272 -0
- package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
- package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
- package/extensions/ahp/internal/mapper/title.go +34 -0
- package/extensions/ahp/internal/mapper/usermsg.go +87 -0
- package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
- package/extensions/ahp/internal/pi/backend.go +64 -0
- package/extensions/ahp/internal/pi/catalogue.go +370 -0
- package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
- package/extensions/ahp/internal/pi/chatdriver.go +533 -0
- package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
- package/extensions/ahp/internal/pi/clientactions.go +212 -0
- package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
- package/extensions/ahp/internal/pi/completions.go +230 -0
- package/extensions/ahp/internal/pi/completions_test.go +272 -0
- package/extensions/ahp/internal/pi/config_test.go +269 -0
- package/extensions/ahp/internal/pi/deletesession.go +69 -0
- package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
- package/extensions/ahp/internal/pi/disposal_test.go +362 -0
- package/extensions/ahp/internal/pi/fixture_test.go +308 -0
- package/extensions/ahp/internal/pi/foreign_test.go +122 -0
- package/extensions/ahp/internal/pi/harness_test.go +83 -0
- package/extensions/ahp/internal/pi/history.go +269 -0
- package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
- package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
- package/extensions/ahp/internal/pi/hydration_test.go +209 -0
- package/extensions/ahp/internal/pi/hydrator.go +212 -0
- package/extensions/ahp/internal/pi/imageinput.go +224 -0
- package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
- package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
- package/extensions/ahp/internal/pi/models.go +212 -0
- package/extensions/ahp/internal/pi/models_test.go +128 -0
- package/extensions/ahp/internal/pi/paging.go +95 -0
- package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
- package/extensions/ahp/internal/pi/projecttrust.go +222 -0
- package/extensions/ahp/internal/pi/registry.go +924 -0
- package/extensions/ahp/internal/pi/restart_test.go +185 -0
- package/extensions/ahp/internal/pi/services.go +70 -0
- package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
- package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
- package/extensions/ahp/internal/pi/sessionstore.go +83 -0
- package/extensions/ahp/internal/pi/summary_test.go +426 -0
- package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
- package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
- package/extensions/ahp/internal/pisession/json.go +31 -0
- package/extensions/ahp/internal/pisession/pisession.go +580 -0
- package/extensions/ahp/internal/settings/access.go +63 -0
- package/extensions/ahp/internal/settings/access_test.go +52 -0
- package/extensions/ahp/internal/settings/settings.go +117 -0
- package/extensions/ahp/internal/settings/settings_test.go +104 -0
- package/extensions/ahp/internal/svc/etag_other.go +15 -0
- package/extensions/ahp/internal/svc/etag_unix.go +34 -0
- package/extensions/ahp/internal/svc/glob.go +197 -0
- package/extensions/ahp/internal/svc/mime.go +37 -0
- package/extensions/ahp/internal/svc/paths.go +108 -0
- package/extensions/ahp/internal/svc/pty.go +64 -0
- package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
- package/extensions/ahp/internal/svc/pty_linux.go +36 -0
- package/extensions/ahp/internal/svc/pty_other.go +9 -0
- package/extensions/ahp/internal/svc/pty_test.go +114 -0
- package/extensions/ahp/internal/svc/pty_unix.go +147 -0
- package/extensions/ahp/internal/svc/resource.go +495 -0
- package/extensions/ahp/internal/svc/resource_test.go +501 -0
- package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
- package/extensions/ahp/internal/svc/stat_linux.go +9 -0
- package/extensions/ahp/internal/svc/terminal.go +454 -0
- package/extensions/ahp/internal/svc/terminal_test.go +512 -0
- package/extensions/ahp/internal/svc/watch.go +590 -0
- package/extensions/ahp/internal/svc/watch_test.go +627 -0
- package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
- package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
- package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
- package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
- package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
- package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
- package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
- package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
- package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
- package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
- package/extensions/ahp/internal/testkit/schema.go +360 -0
- package/extensions/ahp/internal/testkit/testkit.go +367 -0
- package/extensions/ahp/internal/twin/twin.go +27 -0
- package/extensions/ahp/internal/wire/dispatchable.go +106 -0
- package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
- package/extensions/ahp/internal/wire/helpers_test.go +5 -0
- package/extensions/ahp/internal/wire/wire.go +218 -0
- package/extensions/ahp/internal/wire/wire_test.go +123 -0
- package/extensions/ahp/internal/ws/helpers_test.go +7 -0
- package/extensions/ahp/internal/ws/vscode_test.go +198 -0
- package/extensions/ahp/internal/ws/ws.go +674 -0
- package/extensions/ahp/internal/ws/ws_test.go +324 -0
- package/extensions/ahp/realpig_test.go +589 -0
- package/extensions/ahp/runtime.go +311 -0
- package/extensions/ahp/runtime_test.go +110 -0
- package/extensions/ahp/testdata/upstream-tests.json +3833 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
- package/extensions/ahp/twins_test.go +91 -0
- package/package.json +40 -0
- package/proof/PORT.md +121 -0
- package/proof/mutations.json +152 -0
- package/proof/oracle/LICENSE +21 -0
- package/proof/oracle/README.md +120 -0
- package/proof/oracle/UPSTREAM.md +7 -0
- package/proof/oracle/package.json +68 -0
- package/proof/oracle/src/bin/cli.ts +66 -0
- package/proof/oracle/src/bin/tunnel.ts +128 -0
- package/proof/oracle/src/channels/chat.ts +134 -0
- package/proof/oracle/src/channels/root.ts +39 -0
- package/proof/oracle/src/channels/session.ts +118 -0
- package/proof/oracle/src/channels/terminal.ts +11 -0
- package/proof/oracle/src/core/channels.ts +108 -0
- package/proof/oracle/src/core/client-workarounds.ts +358 -0
- package/proof/oracle/src/core/connection.ts +41 -0
- package/proof/oracle/src/core/host.ts +882 -0
- package/proof/oracle/src/core/sequencer.ts +75 -0
- package/proof/oracle/src/core/state-store.ts +162 -0
- package/proof/oracle/src/core/uri.ts +25 -0
- package/proof/oracle/src/host/direct-settings.ts +110 -0
- package/proof/oracle/src/host/pi-host.ts +220 -0
- package/proof/oracle/src/host/serve.ts +71 -0
- package/proof/oracle/src/host/terminal-service.ts +346 -0
- package/proof/oracle/src/pi/activity.ts +171 -0
- package/proof/oracle/src/pi/changeset-service.ts +646 -0
- package/proof/oracle/src/pi/changeset-uri.ts +64 -0
- package/proof/oracle/src/pi/chat-driver.ts +529 -0
- package/proof/oracle/src/pi/completions.ts +160 -0
- package/proof/oracle/src/pi/delete-session.ts +53 -0
- package/proof/oracle/src/pi/event-mapper.ts +648 -0
- package/proof/oracle/src/pi/git-changes.ts +605 -0
- package/proof/oracle/src/pi/history.ts +305 -0
- package/proof/oracle/src/pi/image-input.ts +56 -0
- package/proof/oracle/src/pi/image-mime.ts +8 -0
- package/proof/oracle/src/pi/in-process-backend.ts +155 -0
- package/proof/oracle/src/pi/message-input.ts +212 -0
- package/proof/oracle/src/pi/models.ts +124 -0
- package/proof/oracle/src/pi/project-trust.ts +66 -0
- package/proof/oracle/src/pi/provider.ts +2 -0
- package/proof/oracle/src/pi/resource-paths.ts +80 -0
- package/proof/oracle/src/pi/resource-service.ts +356 -0
- package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
- package/proof/oracle/src/pi/resource-watch.ts +357 -0
- package/proof/oracle/src/pi/session-catalogue.ts +318 -0
- package/proof/oracle/src/pi/session-config.ts +84 -0
- package/proof/oracle/src/pi/session-history.ts +75 -0
- package/proof/oracle/src/pi/session-hydrator.ts +219 -0
- package/proof/oracle/src/pi/session-registry.ts +815 -0
- package/proof/oracle/src/pi/session-storage.ts +29 -0
- package/proof/oracle/src/pi/session-title.ts +18 -0
- package/proof/oracle/src/pi/turn-paging.ts +94 -0
- package/proof/oracle/src/pi/user-message.ts +66 -0
- package/proof/oracle/src/protocol/errors.ts +44 -0
- package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
- package/proof/oracle/src/protocol/version.ts +39 -0
- package/proof/oracle/src/transport/websocket.ts +136 -0
- package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
- package/proof/oracle/src/tunnel/discovery.ts +37 -0
- package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
- package/proof/oracle/test/activity.test.ts +171 -0
- package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
- package/proof/oracle/test/changeset-uri.test.ts +37 -0
- package/proof/oracle/test/changeset.test.serial.ts +487 -0
- package/proof/oracle/test/chat-driver.test.ts +691 -0
- package/proof/oracle/test/client-actions.test.ts +409 -0
- package/proof/oracle/test/client-workarounds.test.ts +320 -0
- package/proof/oracle/test/completions.test.ts +323 -0
- package/proof/oracle/test/delete-session.test.ts +142 -0
- package/proof/oracle/test/direct-settings.test.ts +81 -0
- package/proof/oracle/test/event-mapper.test.ts +619 -0
- package/proof/oracle/test/fetch-turns.test.ts +207 -0
- package/proof/oracle/test/fixtures/abort.json +711 -0
- package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
- package/proof/oracle/test/fixtures/compaction.json +6285 -0
- package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
- package/proof/oracle/test/fixtures/plain-text.json +1856 -0
- package/proof/oracle/test/fixtures/single-tool.json +1128 -0
- package/proof/oracle/test/fixtures/steering.json +3926 -0
- package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
- package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
- package/proof/oracle/test/fixtures/tool-error.json +2850 -0
- package/proof/oracle/test/fixtures/tool-find.json +1162 -0
- package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
- package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
- package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
- package/proof/oracle/test/fixtures/tool-write.json +1271 -0
- package/proof/oracle/test/handshake.test.ts +275 -0
- package/proof/oracle/test/harness.ts +153 -0
- package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
- package/proof/oracle/test/image-input.test.ts +28 -0
- package/proof/oracle/test/image-session.test.ts +69 -0
- package/proof/oracle/test/live-turn.test.ts +304 -0
- package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
- package/proof/oracle/test/message-input.test.ts +181 -0
- package/proof/oracle/test/model-discovery.test.ts +170 -0
- package/proof/oracle/test/models.test.ts +109 -0
- package/proof/oracle/test/pi-host.test.ts +91 -0
- package/proof/oracle/test/pi-replay.test.ts +141 -0
- package/proof/oracle/test/project-trust.test.ts +125 -0
- package/proof/oracle/test/protocol-surface.test.ts +121 -0
- package/proof/oracle/test/pty.test.ts +100 -0
- package/proof/oracle/test/reconnect.test.ts +389 -0
- package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
- package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
- package/proof/oracle/test/resource.test.ts +499 -0
- package/proof/oracle/test/schema.test.ts +88 -0
- package/proof/oracle/test/session-catalogue.test.ts +314 -0
- package/proof/oracle/test/session-config.test.ts +224 -0
- package/proof/oracle/test/session-disposal.test.ts +364 -0
- package/proof/oracle/test/session-hydration.test.ts +194 -0
- package/proof/oracle/test/session-lifecycle.test.ts +416 -0
- package/proof/oracle/test/session-storage.test.serial.ts +32 -0
- package/proof/oracle/test/session-summary.test.ts +418 -0
- package/proof/oracle/test/subscriptions.test.ts +198 -0
- package/proof/oracle/test/support/assertions.ts +32 -0
- package/proof/oracle/test/support/async.ts +26 -0
- package/proof/oracle/test/support/hydrated-session.ts +205 -0
- package/proof/oracle/test/support/images.ts +8 -0
- package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
- package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
- package/proof/oracle/test/support/replay.ts +194 -0
- package/proof/oracle/test/support/schema.ts +221 -0
- package/proof/oracle/test/support/session-files.ts +12 -0
- package/proof/oracle/test/support/session-storage.ts +22 -0
- package/proof/oracle/test/support/upstream.ts +25 -0
- package/proof/oracle/test/support/watch-events.ts +108 -0
- package/proof/oracle/test/terminal-service.test.ts +495 -0
- package/proof/oracle/test/truncate.test.ts +230 -0
- package/proof/oracle/test/tunnel.test.ts +258 -0
- package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
- package/proof/oracle/test/uri.test.ts +24 -0
- package/proof/oracle/test/watch-events.test.ts +118 -0
- package/proof/oracle/tsconfig.json +32 -0
- package/proof/proof-piglet/piglet.yaml +18 -0
- package/proof/tools/fakellm/go.mod +3 -0
- package/proof/tools/fakellm/main.go +109 -0
- package/proof/tools/gen-dispatchable.py +30 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
package host_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"reflect"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
10
|
+
|
|
11
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/channels"
|
|
12
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const reconnectClientID = "reconnecting-client"
|
|
19
|
+
|
|
20
|
+
func bumpActiveSessions(h *host.Host, n int64) {
|
|
21
|
+
h.DispatchServerAction(wire.RootChannel, rootActiveSessions(n))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type replayResult struct {
|
|
25
|
+
Type string `json:"type"`
|
|
26
|
+
Actions []ahptypes.ActionEnvelope `json:"actions"`
|
|
27
|
+
Missing []string `json:"missing"`
|
|
28
|
+
Snaps []ahptypes.Snapshot `json:"snapshots"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func reconnect(t *testing.T, c *testkit.Client, clientID string, lastSeen int64, subs []string) replayResult {
|
|
32
|
+
t.Helper()
|
|
33
|
+
var r replayResult
|
|
34
|
+
c.Decode(c.Must("reconnect", rootParams(map[string]any{"clientId": clientID, "lastSeenServerSeq": lastSeen, "subscriptions": subs})), &r)
|
|
35
|
+
return r
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Twins of upstream test/reconnect.test.ts, describe "reconnect".
|
|
39
|
+
func TestReconnect(t *testing.T) {
|
|
40
|
+
// A tiny buffer makes the eviction path cheap to exercise.
|
|
41
|
+
h := testkit.NewHost(host.Options{ReplayBufferCapacity: 4})
|
|
42
|
+
|
|
43
|
+
twin.Run(t, "reconnect", "keeps non-VS Code and explicitly misrouted reconnects strict", func(t *testing.T) {
|
|
44
|
+
c := testkit.Connect(t, h)
|
|
45
|
+
for _, tc := range []struct {
|
|
46
|
+
context string
|
|
47
|
+
params map[string]any
|
|
48
|
+
}{
|
|
49
|
+
{"non-VS Code reconnect without a channel", map[string]any{"clientId": reconnectClientID + "-missing-channel", "lastSeenServerSeq": 0, "subscriptions": []string{wire.RootChannel}}},
|
|
50
|
+
{"VS Code reconnect with the wrong channel", map[string]any{
|
|
51
|
+
"channel": wire.SessionURI("wrong-channel"), "clientId": reconnectClientID + "-wrong-channel", "lastSeenServerSeq": 0,
|
|
52
|
+
"subscriptions": []string{wire.RootChannel}, "_meta": map[string]any{"vscode.telemetryLevel": "off"},
|
|
53
|
+
}},
|
|
54
|
+
} {
|
|
55
|
+
e := c.ExpectError("reconnect", tc.params, wire.CodeInvalidParams)
|
|
56
|
+
if !strings.HasSuffix(e.Message, "reconnect requires channel ahp-root://") {
|
|
57
|
+
t.Errorf("%s: message %q", tc.context, e.Message)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
twin.Run(t, "reconnect", "replays only the actions the client missed", func(t *testing.T) {
|
|
63
|
+
first := testkit.Connect(t, h)
|
|
64
|
+
init := first.Initialize(reconnectClientID, map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
65
|
+
bumpActiveSessions(h, 1)
|
|
66
|
+
bumpActiveSessions(h, 2)
|
|
67
|
+
first.Close()
|
|
68
|
+
|
|
69
|
+
r := reconnect(t, testkit.Connect(t, h), reconnectClientID, init.ServerSeq, []string{wire.RootChannel})
|
|
70
|
+
if r.Type != "replay" || len(r.Actions) != 2 {
|
|
71
|
+
t.Fatalf("result = %+v", r)
|
|
72
|
+
}
|
|
73
|
+
if r.Actions[0].ServerSeq != init.ServerSeq+1 || r.Actions[1].ServerSeq != init.ServerSeq+2 {
|
|
74
|
+
t.Fatalf("serverSeqs = %d, %d (from %d)", r.Actions[0].ServerSeq, r.Actions[1].ServerSeq, init.ServerSeq)
|
|
75
|
+
}
|
|
76
|
+
if len(r.Missing) != 0 {
|
|
77
|
+
t.Fatalf("missing = %v", r.Missing)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
twin.Run(t, "reconnect", "falls back to snapshots when the gap predates the replay buffer", func(t *testing.T) {
|
|
82
|
+
c := testkit.Connect(t, h)
|
|
83
|
+
c.Initialize(reconnectClientID+"-gap", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
84
|
+
stale := h.ServerSeq()
|
|
85
|
+
// Overflow the 4-entry buffer so stale+1 is evicted.
|
|
86
|
+
for i := int64(0); i < 6; i++ {
|
|
87
|
+
bumpActiveSessions(h, i)
|
|
88
|
+
}
|
|
89
|
+
c.Close()
|
|
90
|
+
r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-gap", stale, []string{wire.RootChannel})
|
|
91
|
+
if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != wire.RootChannel || r.Snaps[0].FromSeq != h.ServerSeq() {
|
|
92
|
+
t.Fatalf("result = %+v (serverSeq %d)", r, h.ServerSeq())
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
twin.Run(t, "reconnect", "reports subscriptions it cannot resume as missing", func(t *testing.T) {
|
|
97
|
+
c := testkit.Connect(t, h)
|
|
98
|
+
init := c.Initialize(reconnectClientID+"-missing", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
99
|
+
disposed := wire.SessionURI("already-gone")
|
|
100
|
+
c.Close()
|
|
101
|
+
r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-missing", init.ServerSeq, []string{wire.RootChannel, disposed})
|
|
102
|
+
if r.Type != "replay" || !reflect.DeepEqual(r.Missing, []string{disposed}) {
|
|
103
|
+
t.Fatalf("result = %+v", r)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
twin.Run(t, "reconnect", "returns an empty replay for a client that is already current", func(t *testing.T) {
|
|
108
|
+
c := testkit.Connect(t, h)
|
|
109
|
+
c.Initialize(reconnectClientID+"-current", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
110
|
+
c.Close()
|
|
111
|
+
r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-current", h.ServerSeq(), []string{wire.RootChannel})
|
|
112
|
+
if r.Type != "replay" || r.Actions == nil || len(r.Actions) != 0 {
|
|
113
|
+
t.Fatalf("result = %+v (actions must be an empty array)", r)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
twin.Run(t, "reconnect", "replaces a half-open connection that reuses a clientId", func(t *testing.T) {
|
|
118
|
+
clientID := reconnectClientID + "-duplicate"
|
|
119
|
+
before := h.SubscriberCount(wire.RootChannel)
|
|
120
|
+
first := testkit.Connect(t, h)
|
|
121
|
+
first.Initialize(clientID, nil)
|
|
122
|
+
first.Subscribe(wire.RootChannel)
|
|
123
|
+
if got := h.SubscriberCount(wire.RootChannel); got != before+1 {
|
|
124
|
+
t.Fatalf("subscribers = %d, want %d", got, before+1)
|
|
125
|
+
}
|
|
126
|
+
replacement := testkit.Connect(t, h)
|
|
127
|
+
replacement.Initialize(clientID, nil)
|
|
128
|
+
replacement.Subscribe(wire.RootChannel)
|
|
129
|
+
if got := h.SubscriberCount(wire.RootChannel); got != before+1 {
|
|
130
|
+
t.Fatalf("subscribers = %d, want %d", got, before+1)
|
|
131
|
+
}
|
|
132
|
+
// The host closes the replaced transport on its own goroutine (a Close that blocks must not stall the handshake).
|
|
133
|
+
testkit.Eventually(t, "the half-open connection to be closed", first.ServerClosed)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
twin.Run(t, "reconnect", "retains clientInfo when a replacement initialize omits it", func(t *testing.T) {
|
|
137
|
+
clientID := reconnectClientID + "-reinitialize"
|
|
138
|
+
id := "vscode-reinitialize"
|
|
139
|
+
mustCreate(t, h, wire.SessionURI(id), channels.InitialSessionState("pi", "Reinitialize", "/tmp"), wire.KindSession)
|
|
140
|
+
first := testkit.Connect(t, h)
|
|
141
|
+
first.Must("initialize", rootParams(map[string]any{"clientId": clientID, "protocolVersions": testkit.SupportedVersions(), "clientInfo": map[string]any{"name": "vscode-editor-window"}}))
|
|
142
|
+
first.Close()
|
|
143
|
+
replacement := testkit.Connect(t, h)
|
|
144
|
+
replacement.Initialize(clientID, nil)
|
|
145
|
+
clientURI := "pi:/" + id
|
|
146
|
+
if got := replacement.Subscribe(clientURI); got.Snapshot == nil || got.Snapshot.Resource != clientURI {
|
|
147
|
+
t.Fatalf("subscribe(%s) = %+v", clientURI, got)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
twin.Run(t, "reconnect", "infers the provider URI dialect without clientInfo", func(t *testing.T) {
|
|
152
|
+
clientID := reconnectClientID + "-provider-alias"
|
|
153
|
+
id := "provider-alias-reconnect"
|
|
154
|
+
canonical, alias := wire.SessionURI(id), "pi:/"+id
|
|
155
|
+
mustCreate(t, h, canonical, channels.InitialSessionState("pi", "Reconnect", "/tmp"), wire.KindSession)
|
|
156
|
+
first := testkit.Connect(t, h)
|
|
157
|
+
first.Initialize(clientID, nil)
|
|
158
|
+
if got := first.Subscribe(alias); got.Snapshot == nil || got.Snapshot.Resource != alias {
|
|
159
|
+
t.Fatalf("subscribe = %+v", got)
|
|
160
|
+
}
|
|
161
|
+
stale := h.ServerSeq()
|
|
162
|
+
first.Close()
|
|
163
|
+
for i := int64(0); i < 6; i++ {
|
|
164
|
+
bumpActiveSessions(h, i)
|
|
165
|
+
}
|
|
166
|
+
r := reconnect(t, testkit.Connect(t, h), clientID, stale, []string{alias})
|
|
167
|
+
if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != alias {
|
|
168
|
+
t.Fatalf("result = %+v", r)
|
|
169
|
+
}
|
|
170
|
+
if !h.Store().Has(canonical) || h.Store().Has(alias) {
|
|
171
|
+
t.Fatal("core state stays canonical")
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
twin.Run(t, "reconnect", "retains VS Code's URI dialect across connections", func(t *testing.T) {
|
|
176
|
+
clientID := reconnectClientID + "-vscode"
|
|
177
|
+
id := "vscode-reconnect"
|
|
178
|
+
clientURI := "pi:/" + id
|
|
179
|
+
mustCreate(t, h, wire.SessionURI(id), channels.InitialSessionState("pi", "Reconnect", "/tmp"), wire.KindSession)
|
|
180
|
+
first := testkit.Connect(t, h)
|
|
181
|
+
first.Must("initialize", rootParams(map[string]any{"clientId": clientID, "protocolVersions": testkit.SupportedVersions(), "clientInfo": map[string]any{"name": "vscode-editor-window"}}))
|
|
182
|
+
if got := first.Subscribe(clientURI); got.Snapshot == nil || got.Snapshot.Resource != clientURI {
|
|
183
|
+
t.Fatalf("subscribe = %+v", got)
|
|
184
|
+
}
|
|
185
|
+
stale := h.ServerSeq()
|
|
186
|
+
first.Close()
|
|
187
|
+
for i := int64(0); i < 6; i++ {
|
|
188
|
+
bumpActiveSessions(h, i)
|
|
189
|
+
}
|
|
190
|
+
r := reconnect(t, testkit.Connect(t, h), clientID, stale, []string{clientURI})
|
|
191
|
+
if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != clientURI {
|
|
192
|
+
t.Fatalf("result = %+v", r)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
var _ = json.Marshal
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package host_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
7
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// The replay buffer serves a client only when it still holds every action after lastSeenServerSeq:
|
|
12
|
+
// one action past the edge must fall back to fresh snapshots, never a replay with a hole in it.
|
|
13
|
+
func TestReplayWindowEdge(t *testing.T) {
|
|
14
|
+
h := testkit.NewHost(host.Options{ReplayBufferCapacity: 4})
|
|
15
|
+
c := testkit.Connect(t, h)
|
|
16
|
+
c.Initialize("edge-client", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
17
|
+
c.Close()
|
|
18
|
+
seen := h.ServerSeq()
|
|
19
|
+
for i := 0; i < 4; i++ {
|
|
20
|
+
bumpActiveSessions(h, int64(i))
|
|
21
|
+
}
|
|
22
|
+
inside := reconnect(t, testkit.Connect(t, h), "edge-client", seen, []string{wire.RootChannel})
|
|
23
|
+
if inside.Type != "replay" || len(inside.Actions) != 4 {
|
|
24
|
+
t.Fatalf("the buffer still holds every action after lastSeen: type=%s actions=%d", inside.Type, len(inside.Actions))
|
|
25
|
+
}
|
|
26
|
+
bumpActiveSessions(h, 9) // evicts the oldest action the client has not seen
|
|
27
|
+
outside := reconnect(t, testkit.Connect(t, h), "edge-client", seen, []string{wire.RootChannel})
|
|
28
|
+
if outside.Type != "snapshot" || len(outside.Snaps) != 1 {
|
|
29
|
+
t.Fatalf("one action beyond the buffer must fall back to snapshots: type=%s snapshots=%d", outside.Type, len(outside.Snaps))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package host_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
11
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Twins of upstream test/schema.test.ts, describe "wire schema".
|
|
15
|
+
func TestWireSchema(t *testing.T) {
|
|
16
|
+
h := testkit.NewHost(host.Options{})
|
|
17
|
+
|
|
18
|
+
twin.Run(t, "schema", "emits a conforming InitializeResult", func(t *testing.T) {
|
|
19
|
+
c := testkit.Connect(t, h)
|
|
20
|
+
res := c.Initialize(nextClientID(), map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
|
|
21
|
+
testkit.AssertValid(t, "commands", "InitializeResult", res)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
twin.Run(t, "schema", "emits a conforming RootState snapshot", func(t *testing.T) {
|
|
25
|
+
c := initialized(t, h)
|
|
26
|
+
res := c.Subscribe(wire.RootChannel)
|
|
27
|
+
if res.Snapshot == nil {
|
|
28
|
+
t.Fatal("no snapshot")
|
|
29
|
+
}
|
|
30
|
+
testkit.AssertValid(t, "commands", "Snapshot", res.Snapshot)
|
|
31
|
+
testkit.AssertValid(t, "state", "RootState", res.Snapshot.State.Root)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
twin.Run(t, "schema", "emits conforming ActionEnvelopes", func(t *testing.T) {
|
|
35
|
+
c := initialized(t, h, wire.RootChannel)
|
|
36
|
+
h.DispatchServerAction(wire.RootChannel, ahptypes.StateAction{Value: &ahptypes.RootActiveSessionsChangedAction{
|
|
37
|
+
Type: ahptypes.ActionTypeRootActiveSessionsChanged, ActiveSessions: 3,
|
|
38
|
+
}})
|
|
39
|
+
env := c.NextEnvelope(wire.RootChannel, 0)
|
|
40
|
+
testkit.AssertValid(t, "actions", "ActionEnvelope", env)
|
|
41
|
+
testkit.AssertValid(t, "actions", "StateAction", env.Action)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
twin.Run(t, "schema", "fails validation for a structurally wrong action", func(t *testing.T) {
|
|
45
|
+
// A reducer would accept this silently (unknown shape becomes a no-op); only the schema catches it.
|
|
46
|
+
bogus := map[string]any{
|
|
47
|
+
"channel": wire.RootChannel,
|
|
48
|
+
"action": map[string]any{"type": "root/activeSessionsChanged"},
|
|
49
|
+
"serverSeq": 1,
|
|
50
|
+
}
|
|
51
|
+
if testkit.CheckSchema("actions", "ActionEnvelope", bogus) == nil {
|
|
52
|
+
t.Fatal("the schema accepted an action without its required field")
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Twins of upstream test/upstream-workarounds.test.ts: they fail once a spec re-sync no longer
|
|
58
|
+
// needs the workarounds the schema validator applies.
|
|
59
|
+
func TestUpstreamWorkarounds(t *testing.T) {
|
|
60
|
+
if err := testkit.LoadSchemas(); err != nil {
|
|
61
|
+
t.Fatal(err)
|
|
62
|
+
}
|
|
63
|
+
twin.Run(t, "upstream-workarounds", "still needs the dangling-$ref strip", func(t *testing.T) {
|
|
64
|
+
if testkit.StrippedDanglingRefs == 0 {
|
|
65
|
+
t.Fatal("the schemas no longer contain a dangling `$ref` to an empty `$defs` name: delete the strip from internal/testkit/schema.go and this test")
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
twin.Run(t, "upstream-workarounds", "still needs every KNOWN_BITSET_ENUMS entry", func(t *testing.T) {
|
|
69
|
+
found := false
|
|
70
|
+
for _, e := range testkit.RelaxedBitsetEnums {
|
|
71
|
+
if len(e) >= len("SessionStatus") && e[len(e)-len("SessionStatus"):] == "SessionStatus" {
|
|
72
|
+
found = true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if !found {
|
|
76
|
+
t.Fatal("SessionStatus is no longer emitted as a closed enum: remove the relaxation from internal/testkit/schema.go")
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
package host
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sync"
|
|
7
|
+
|
|
8
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahp"
|
|
9
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
10
|
+
|
|
11
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// maxRetainedTerminalChars bounds the output kept for snapshots; live clients still receive every action.
|
|
15
|
+
const maxRetainedTerminalChars = 1_000_000
|
|
16
|
+
|
|
17
|
+
// Store holds the state tree of every state-bearing channel. Every state is mutated only by
|
|
18
|
+
// actions run through the protocol's reducers (the vendored Microsoft Go reducers), and reads
|
|
19
|
+
// return deep copies, so a caller never observes a later mutation. Port of src/core/state-store.ts.
|
|
20
|
+
type Store struct {
|
|
21
|
+
mu sync.RWMutex
|
|
22
|
+
channels map[string]*entry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type entry struct {
|
|
26
|
+
kind wire.ChannelKind
|
|
27
|
+
state any // *RootState, *SessionState, *ChatState, *TerminalState or *ResourceWatchState
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func newStore() *Store { return &Store{channels: map[string]*entry{}} }
|
|
31
|
+
|
|
32
|
+
// Create registers a channel with its initial state (a pointer to the typed state). The kind is
|
|
33
|
+
// inferred from the scheme unless given, which callers do when a command establishes the kind of
|
|
34
|
+
// a client-chosen URI. It replaces any existing entry.
|
|
35
|
+
func (s *Store) Create(uri string, state any, kind ...wire.ChannelKind) error {
|
|
36
|
+
var k wire.ChannelKind
|
|
37
|
+
if len(kind) > 0 && kind[0] != "" {
|
|
38
|
+
k = kind[0]
|
|
39
|
+
} else if inferred, ok := wire.KindOf(uri); ok {
|
|
40
|
+
k = inferred
|
|
41
|
+
} else {
|
|
42
|
+
return fmt.Errorf("cannot create channel with unknown scheme: %s", uri)
|
|
43
|
+
}
|
|
44
|
+
if !stateMatchesKind(state, k) {
|
|
45
|
+
return fmt.Errorf("state %T does not belong to a %s channel", state, k)
|
|
46
|
+
}
|
|
47
|
+
s.mu.Lock()
|
|
48
|
+
s.channels[uri] = &entry{kind: k, state: cloneState(state)}
|
|
49
|
+
s.mu.Unlock()
|
|
50
|
+
return nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// UpdateChat edits a chat's state in place under the store lock, so an edit cannot overwrite an
|
|
54
|
+
// action reduced between reading the state and writing it back. fn reports whether it changed
|
|
55
|
+
// anything; UpdateChat returns false when the chat does not exist or fn made no change.
|
|
56
|
+
func (s *Store) UpdateChat(uri string, fn func(*ahptypes.ChatState) bool) bool {
|
|
57
|
+
s.mu.Lock()
|
|
58
|
+
defer s.mu.Unlock()
|
|
59
|
+
e, ok := s.channels[uri]
|
|
60
|
+
if !ok {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
chat, ok := e.state.(*ahptypes.ChatState)
|
|
64
|
+
if !ok {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
return fn(chat)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func stateMatchesKind(state any, kind wire.ChannelKind) bool {
|
|
71
|
+
switch state.(type) {
|
|
72
|
+
case *ahptypes.RootState:
|
|
73
|
+
return kind == wire.KindRoot
|
|
74
|
+
case *ahptypes.SessionState:
|
|
75
|
+
return kind == wire.KindSession
|
|
76
|
+
case *ahptypes.ChatState:
|
|
77
|
+
return kind == wire.KindChat
|
|
78
|
+
case *ahptypes.TerminalState:
|
|
79
|
+
return kind == wire.KindTerminal
|
|
80
|
+
case *ahptypes.ChangesetState:
|
|
81
|
+
return kind == wire.KindChangeset
|
|
82
|
+
case *ahptypes.ResourceWatchState:
|
|
83
|
+
return kind == wire.KindResourceWatch
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// cloneState deep-copies a typed state through its JSON form (the wire form is the contract).
|
|
89
|
+
func cloneState(state any) any {
|
|
90
|
+
switch v := state.(type) {
|
|
91
|
+
case *ahptypes.RootState:
|
|
92
|
+
return cloneVia(v)
|
|
93
|
+
case *ahptypes.SessionState:
|
|
94
|
+
return cloneVia(v)
|
|
95
|
+
case *ahptypes.ChatState:
|
|
96
|
+
return cloneVia(v)
|
|
97
|
+
case *ahptypes.TerminalState:
|
|
98
|
+
return cloneVia(v)
|
|
99
|
+
case *ahptypes.ChangesetState:
|
|
100
|
+
return cloneVia(v)
|
|
101
|
+
case *ahptypes.ResourceWatchState:
|
|
102
|
+
return cloneVia(v)
|
|
103
|
+
}
|
|
104
|
+
return state
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func cloneVia[T any](v *T) *T {
|
|
108
|
+
if v == nil {
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
b, err := json.Marshal(v)
|
|
112
|
+
if err != nil {
|
|
113
|
+
panic(fmt.Sprintf("host: state is not marshalable: %v", err))
|
|
114
|
+
}
|
|
115
|
+
out := new(T)
|
|
116
|
+
if err := json.Unmarshal(b, out); err != nil {
|
|
117
|
+
panic(fmt.Sprintf("host: state does not round-trip: %v", err))
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Delete removes a channel.
|
|
123
|
+
func (s *Store) Delete(uri string) bool {
|
|
124
|
+
s.mu.Lock()
|
|
125
|
+
defer s.mu.Unlock()
|
|
126
|
+
_, ok := s.channels[uri]
|
|
127
|
+
delete(s.channels, uri)
|
|
128
|
+
return ok
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Has reports whether a channel exists.
|
|
132
|
+
func (s *Store) Has(uri string) bool {
|
|
133
|
+
s.mu.RLock()
|
|
134
|
+
defer s.mu.RUnlock()
|
|
135
|
+
_, ok := s.channels[uri]
|
|
136
|
+
return ok
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// KindOf returns the kind of an existing channel.
|
|
140
|
+
func (s *Store) KindOf(uri string) (wire.ChannelKind, bool) {
|
|
141
|
+
s.mu.RLock()
|
|
142
|
+
defer s.mu.RUnlock()
|
|
143
|
+
e, ok := s.channels[uri]
|
|
144
|
+
if !ok {
|
|
145
|
+
return "", false
|
|
146
|
+
}
|
|
147
|
+
return e.kind, true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func get[T any](s *Store, uri string) *T {
|
|
151
|
+
s.mu.RLock()
|
|
152
|
+
defer s.mu.RUnlock()
|
|
153
|
+
e, ok := s.channels[uri]
|
|
154
|
+
if !ok {
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
typed, ok := e.state.(*T)
|
|
158
|
+
if !ok {
|
|
159
|
+
return nil
|
|
160
|
+
}
|
|
161
|
+
return cloneVia(typed)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Root, Session, Chat, Terminal, ResourceWatch return a deep copy of the typed state, nil when
|
|
165
|
+
// the channel is absent or of another kind.
|
|
166
|
+
func (s *Store) Root(uri string) *ahptypes.RootState { return get[ahptypes.RootState](s, uri) }
|
|
167
|
+
func (s *Store) Session(uri string) *ahptypes.SessionState { return get[ahptypes.SessionState](s, uri) }
|
|
168
|
+
func (s *Store) Chat(uri string) *ahptypes.ChatState { return get[ahptypes.ChatState](s, uri) }
|
|
169
|
+
func (s *Store) Terminal(uri string) *ahptypes.TerminalState {
|
|
170
|
+
return get[ahptypes.TerminalState](s, uri)
|
|
171
|
+
}
|
|
172
|
+
func (s *Store) ResourceWatch(uri string) *ahptypes.ResourceWatchState {
|
|
173
|
+
return get[ahptypes.ResourceWatchState](s, uri)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Apply reduces an action into a channel using the reducer for its kind. Reducers are total: an
|
|
177
|
+
// action they do not recognise is a no-op, which is how forward compatibility works. It returns
|
|
178
|
+
// false when the channel does not exist — the spec requires the host to silently ignore actions
|
|
179
|
+
// targeting an unknown channel rather than echoing a rejection.
|
|
180
|
+
func (s *Store) Apply(uri string, action ahptypes.StateAction) (applied bool) {
|
|
181
|
+
s.mu.Lock()
|
|
182
|
+
defer s.mu.Unlock()
|
|
183
|
+
e, ok := s.channels[uri]
|
|
184
|
+
if !ok {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
// The vendored reducers panic on a malformed timestamp; a hostile or buggy client must not take the host down.
|
|
188
|
+
defer func() {
|
|
189
|
+
if r := recover(); r != nil {
|
|
190
|
+
applied = true
|
|
191
|
+
}
|
|
192
|
+
}()
|
|
193
|
+
switch st := e.state.(type) {
|
|
194
|
+
case *ahptypes.RootState:
|
|
195
|
+
ahp.ApplyActionToRoot(st, action)
|
|
196
|
+
case *ahptypes.SessionState:
|
|
197
|
+
ahp.ApplyActionToSession(st, action)
|
|
198
|
+
case *ahptypes.ChatState:
|
|
199
|
+
ahp.ApplyActionToChat(st, action)
|
|
200
|
+
case *ahptypes.TerminalState:
|
|
201
|
+
ahp.ApplyActionToTerminal(st, action)
|
|
202
|
+
if _, isData := action.Value.(*ahptypes.TerminalDataAction); isData {
|
|
203
|
+
trimTerminal(st)
|
|
204
|
+
}
|
|
205
|
+
case *ahptypes.ChangesetState:
|
|
206
|
+
ahp.ApplyActionToChangeset(st, action)
|
|
207
|
+
case *ahptypes.ResourceWatchState:
|
|
208
|
+
ahp.ApplyActionToResourceWatch(st, action)
|
|
209
|
+
}
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Snapshot builds a subscribe/initialize snapshot for a channel at the given seq.
|
|
214
|
+
func (s *Store) Snapshot(uri string, fromSeq int64) (ahptypes.Snapshot, bool) {
|
|
215
|
+
s.mu.RLock()
|
|
216
|
+
defer s.mu.RUnlock()
|
|
217
|
+
e, ok := s.channels[uri]
|
|
218
|
+
if !ok {
|
|
219
|
+
return ahptypes.Snapshot{}, false
|
|
220
|
+
}
|
|
221
|
+
var state ahptypes.SnapshotState
|
|
222
|
+
switch st := e.state.(type) {
|
|
223
|
+
case *ahptypes.RootState:
|
|
224
|
+
state.Root = cloneVia(st)
|
|
225
|
+
case *ahptypes.SessionState:
|
|
226
|
+
state.Session = cloneVia(st)
|
|
227
|
+
case *ahptypes.ChatState:
|
|
228
|
+
state.Chat = cloneVia(st)
|
|
229
|
+
case *ahptypes.TerminalState:
|
|
230
|
+
state.Terminal = cloneVia(st)
|
|
231
|
+
case *ahptypes.ChangesetState:
|
|
232
|
+
state.Changeset = cloneVia(st)
|
|
233
|
+
case *ahptypes.ResourceWatchState:
|
|
234
|
+
state.ResourceWatch = cloneVia(st)
|
|
235
|
+
}
|
|
236
|
+
return ahptypes.Snapshot{Resource: uri, State: state, FromSeq: fromSeq}, true
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// trimTerminal drops only the oldest retained output beyond the cap.
|
|
240
|
+
func trimTerminal(st *ahptypes.TerminalState) {
|
|
241
|
+
remaining := maxRetainedTerminalChars
|
|
242
|
+
trimmed := false
|
|
243
|
+
var newestFirst []ahptypes.TerminalContentPart
|
|
244
|
+
for i := len(st.Content) - 1; i >= 0; i-- {
|
|
245
|
+
part := st.Content[i]
|
|
246
|
+
out := terminalOutput(part)
|
|
247
|
+
runes := []rune(out)
|
|
248
|
+
if len(runes) <= remaining {
|
|
249
|
+
newestFirst = append(newestFirst, part)
|
|
250
|
+
remaining -= len(runes)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
if remaining > 0 {
|
|
254
|
+
newestFirst = append(newestFirst, withTerminalOutput(part, string(runes[len(runes)-remaining:])))
|
|
255
|
+
}
|
|
256
|
+
trimmed = true
|
|
257
|
+
break
|
|
258
|
+
}
|
|
259
|
+
if !trimmed {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
content := make([]ahptypes.TerminalContentPart, 0, len(newestFirst))
|
|
263
|
+
for i := len(newestFirst) - 1; i >= 0; i-- {
|
|
264
|
+
content = append(content, newestFirst[i])
|
|
265
|
+
}
|
|
266
|
+
st.Content = content
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func terminalOutput(p ahptypes.TerminalContentPart) string {
|
|
270
|
+
switch v := p.Value.(type) {
|
|
271
|
+
case *ahptypes.TerminalUnclassifiedPart:
|
|
272
|
+
return v.Value
|
|
273
|
+
case *ahptypes.TerminalCommandPart:
|
|
274
|
+
return v.Output
|
|
275
|
+
}
|
|
276
|
+
return ""
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func withTerminalOutput(p ahptypes.TerminalContentPart, out string) ahptypes.TerminalContentPart {
|
|
280
|
+
switch v := p.Value.(type) {
|
|
281
|
+
case *ahptypes.TerminalUnclassifiedPart:
|
|
282
|
+
c := *v
|
|
283
|
+
c.Value = out
|
|
284
|
+
return ahptypes.TerminalContentPart{Value: &c}
|
|
285
|
+
case *ahptypes.TerminalCommandPart:
|
|
286
|
+
c := *v
|
|
287
|
+
c.Output = out
|
|
288
|
+
return ahptypes.TerminalContentPart{Value: &c}
|
|
289
|
+
}
|
|
290
|
+
return p
|
|
291
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package host_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"sync"
|
|
5
|
+
"testing"
|
|
6
|
+
"time"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
11
|
+
ahptypes "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// UpdateChat is a read-modify-write under the store lock: edits racing with reduced actions must
|
|
15
|
+
// never drop either side (a title rewrite that replaced the whole state once erased a running turn).
|
|
16
|
+
func TestUpdateChatDoesNotLoseConcurrentActions(t *testing.T) {
|
|
17
|
+
const turns = 200
|
|
18
|
+
s := testkit.NewHost(host.Options{}).Store()
|
|
19
|
+
uri := "ahp-chat:/00000000-0000-7000-8000-000000000001"
|
|
20
|
+
if err := s.Create(uri, &ahptypes.ChatState{Resource: uri, Title: "t"}, wire.KindChat); err != nil {
|
|
21
|
+
t.Fatal(err)
|
|
22
|
+
}
|
|
23
|
+
var wg sync.WaitGroup
|
|
24
|
+
wg.Add(2)
|
|
25
|
+
go func() {
|
|
26
|
+
defer wg.Done()
|
|
27
|
+
for i := 0; i < turns; i++ {
|
|
28
|
+
s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "x", StartedAt: "2025-01-01T00:00:00.000Z"}})
|
|
29
|
+
s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "x", Duration: 1}})
|
|
30
|
+
}
|
|
31
|
+
}()
|
|
32
|
+
go func() {
|
|
33
|
+
defer wg.Done()
|
|
34
|
+
for i := 0; i < turns; i++ {
|
|
35
|
+
s.UpdateChat(uri, func(c *ahptypes.ChatState) bool { c.Title = "renamed"; return true })
|
|
36
|
+
}
|
|
37
|
+
}()
|
|
38
|
+
wg.Wait()
|
|
39
|
+
got := s.Chat(uri)
|
|
40
|
+
if len(got.Turns) != turns || got.ActiveTurn != nil {
|
|
41
|
+
t.Fatalf("turns = %d active = %v, want %d complete turns", len(got.Turns), got.ActiveTurn != nil, turns)
|
|
42
|
+
}
|
|
43
|
+
if got.Title != "renamed" {
|
|
44
|
+
t.Fatalf("title = %q", got.Title)
|
|
45
|
+
}
|
|
46
|
+
if s.UpdateChat("ahp-chat:/missing", func(*ahptypes.ChatState) bool { return true }) {
|
|
47
|
+
t.Fatal("updating a missing chat reported a change")
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// An action reduced while an edit is in flight waits for it: the edit sees, and keeps, whatever
|
|
52
|
+
// came before, and the action lands after (deterministic form of the race above; needs no -race).
|
|
53
|
+
func TestUpdateChatExcludesReducers(t *testing.T) {
|
|
54
|
+
s := testkit.NewHost(host.Options{}).Store()
|
|
55
|
+
uri := "ahp-chat:/00000000-0000-7000-8000-000000000002"
|
|
56
|
+
if err := s.Create(uri, &ahptypes.ChatState{Resource: uri}, wire.KindChat); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
inside, applied := make(chan struct{}), make(chan struct{})
|
|
60
|
+
done := make(chan struct{})
|
|
61
|
+
go func() {
|
|
62
|
+
defer close(done)
|
|
63
|
+
s.UpdateChat(uri, func(c *ahptypes.ChatState) bool {
|
|
64
|
+
close(inside)
|
|
65
|
+
go func() {
|
|
66
|
+
s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "x", StartedAt: "2025-01-01T00:00:00.000Z"}})
|
|
67
|
+
close(applied)
|
|
68
|
+
}()
|
|
69
|
+
select {
|
|
70
|
+
case <-applied:
|
|
71
|
+
t.Error("an action was reduced while the edit held the chat")
|
|
72
|
+
case <-time.After(50 * time.Millisecond):
|
|
73
|
+
}
|
|
74
|
+
c.Title = "edited"
|
|
75
|
+
return true
|
|
76
|
+
})
|
|
77
|
+
}()
|
|
78
|
+
<-inside
|
|
79
|
+
<-done
|
|
80
|
+
<-applied
|
|
81
|
+
got := s.Chat(uri)
|
|
82
|
+
if got.Title != "edited" || got.ActiveTurn == nil {
|
|
83
|
+
t.Fatalf("title=%q active=%v: the edit and the action must both survive", got.Title, got.ActiveTurn != nil)
|
|
84
|
+
}
|
|
85
|
+
}
|