@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,272 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"regexp"
|
|
10
|
+
"sort"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
17
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
18
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Twins of upstream test/completions.test.ts: inline completions for a message being composed.
|
|
23
|
+
|
|
24
|
+
const completionsChat = "ahp-chat:/completions"
|
|
25
|
+
|
|
26
|
+
func TestMentionParsing(t *testing.T) {
|
|
27
|
+
twin.Run(t, "completions", "finds a mention the cursor sits in", func(t *testing.T) {
|
|
28
|
+
got, ok := pi.FindMention("look at @foo", 12)
|
|
29
|
+
if !ok || got != (pi.Mention{Start: 8, End: 12, Query: "foo"}) {
|
|
30
|
+
t.Fatalf("got %+v %v", got, ok)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
twin.Run(t, "completions", "finds a bare mention at the start of the text", func(t *testing.T) {
|
|
34
|
+
if got, ok := pi.FindMention("@src", 4); !ok || got.Query != "src" {
|
|
35
|
+
t.Fatalf("got %+v %v", got, ok)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
twin.Run(t, "completions", "ignores an `@` that is not preceded by whitespace", func(t *testing.T) {
|
|
39
|
+
// Otherwise every email address and decorator opens the picker.
|
|
40
|
+
if _, ok := pi.FindMention("mail me at a@b.com", 18); ok {
|
|
41
|
+
t.Fatal("an email address opened the picker")
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
twin.Run(t, "completions", "ignores a cursor that is past the end of the mention", func(t *testing.T) {
|
|
45
|
+
if _, ok := pi.FindMention("@foo bar", 8); ok {
|
|
46
|
+
t.Fatal("the cursor is past the mention")
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
twin.Run(t, "completions", "returns the empty query for a lone trigger", func(t *testing.T) {
|
|
50
|
+
if got, ok := pi.FindMention("@", 1); !ok || got.Query != "" {
|
|
51
|
+
t.Fatalf("got %+v %v", got, ok)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func touch(t *testing.T, path string) {
|
|
57
|
+
t.Helper()
|
|
58
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
|
62
|
+
t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func complete(t *testing.T, s *pi.CompletionService, text string, offset int) ahptypes.CompletionsResult {
|
|
67
|
+
t.Helper()
|
|
68
|
+
r, err := s.Complete(context.Background(), ahptypes.CompletionsParams{Kind: ahptypes.CompletionItemKindUserMessage, Channel: completionsChat, Text: text, Offset: int64(offset)})
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatal(err)
|
|
71
|
+
}
|
|
72
|
+
return r
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func inserted(r ahptypes.CompletionsResult) []string {
|
|
76
|
+
out := []string{}
|
|
77
|
+
for _, i := range r.Items {
|
|
78
|
+
out = append(out, i.InsertText)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func TestCompletions(t *testing.T) {
|
|
84
|
+
workspace := t.TempDir()
|
|
85
|
+
for _, f := range []string{"readme.md", "report.txt", "other.js", ".hidden", "src/index.ts", "src/lib/deep.ts", "node_modules/reactive.js", "dist/generated.js", "build/artifact.js"} {
|
|
86
|
+
touch(t, filepath.Join(workspace, f))
|
|
87
|
+
}
|
|
88
|
+
service := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return workspace }})
|
|
89
|
+
at := func(text string) ahptypes.CompletionsResult { return complete(t, service, text, len([]rune(text))) }
|
|
90
|
+
|
|
91
|
+
twin.Run(t, "completions", "offers files matching the typed prefix", func(t *testing.T) {
|
|
92
|
+
got := inserted(at("look at @re"))
|
|
93
|
+
sort.Strings(got)
|
|
94
|
+
sameJSON(t, got, []string{"@readme.md", "@report.txt"}, "insert texts")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
twin.Run(t, "completions", "attaches the resource each item refers to", func(t *testing.T) {
|
|
98
|
+
r := at("@readme")
|
|
99
|
+
if len(r.Items) == 0 {
|
|
100
|
+
t.Fatal("no items")
|
|
101
|
+
}
|
|
102
|
+
att, ok := r.Items[0].Attachment.Value.(*ahptypes.MessageResourceAttachment)
|
|
103
|
+
if !ok || att.Type != ahptypes.MessageAttachmentKindResource || att.Label != "readme.md" || !regexp.MustCompile(`readme\.md$`).MatchString(att.Uri) {
|
|
104
|
+
t.Fatalf("attachment = %+v", r.Items[0].Attachment.Value)
|
|
105
|
+
}
|
|
106
|
+
testkit.AssertValid(t, "commands", "CompletionItem", r.Items[0])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
twin.Run(t, "completions", "marks the range the client should replace", func(t *testing.T) {
|
|
110
|
+
r := at("look at @re")
|
|
111
|
+
// The span covers the `@` through the cursor, so accepting replaces the half-typed mention
|
|
112
|
+
// rather than appending to it.
|
|
113
|
+
if *r.Items[0].RangeStart != 8 || *r.Items[0].RangeEnd != 11 {
|
|
114
|
+
t.Fatalf("range = %d..%d", *r.Items[0].RangeStart, *r.Items[0].RangeEnd)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
twin.Run(t, "completions", "returns nested files instead of directory navigation items", func(t *testing.T) {
|
|
119
|
+
r := at("@sr")
|
|
120
|
+
sameJSON(t, inserted(r), []string{"@src/index.ts", "@src/lib/deep.ts"}, "insert texts")
|
|
121
|
+
for _, i := range r.Items {
|
|
122
|
+
if k := i.Attachment.Value.(*ahptypes.MessageResourceAttachment).DisplayKind; k == nil || *k != "document" {
|
|
123
|
+
t.Fatalf("displayKind = %v", k)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
twin.Run(t, "completions", "matches a typed relative-path prefix", func(t *testing.T) {
|
|
129
|
+
sameJSON(t, inserted(at("@src/in")), []string{"@src/index.ts"}, "insert texts")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
twin.Run(t, "completions", "matches a nested file by basename", func(t *testing.T) {
|
|
133
|
+
sameJSON(t, inserted(at("@deep")), []string{"@src/lib/deep.ts"}, "insert texts")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
twin.Run(t, "completions", "keeps the temporary directory skip list narrow", func(t *testing.T) {
|
|
137
|
+
got := inserted(at("@"))
|
|
138
|
+
has := func(s string) bool {
|
|
139
|
+
for _, g := range got {
|
|
140
|
+
if g == s {
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
for _, g := range got {
|
|
147
|
+
if regexp.MustCompile(`node_modules|\.hidden`).MatchString(g) {
|
|
148
|
+
t.Fatalf("offered %s", g)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if !has("@dist/generated.js") || !has("@build/artifact.js") {
|
|
152
|
+
t.Fatalf("build output must be offered: %v", got)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
twin.Run(t, "completions", "does not interpret a query as path traversal", func(t *testing.T) {
|
|
157
|
+
if got := at("@../../etc/pass").Items; len(got) != 0 {
|
|
158
|
+
t.Fatalf("items = %v", got)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
twin.Run(t, "completions", "returns nothing for a completion kind it does not implement", func(t *testing.T) {
|
|
163
|
+
r, err := service.Complete(context.Background(), ahptypes.CompletionsParams{Kind: "futureKind", Channel: completionsChat, Text: "@not", Offset: 4})
|
|
164
|
+
if err != nil || len(r.Items) != 0 {
|
|
165
|
+
t.Fatalf("items = %v (%v)", r.Items, err)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
twin.Run(t, "completions", "returns nothing when the cursor is not in a mention", func(t *testing.T) {
|
|
170
|
+
if got := at("plain text").Items; len(got) != 0 {
|
|
171
|
+
t.Fatalf("items = %v", got)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
twin.Run(t, "completions", "returns nothing for a chat it has no directory for", func(t *testing.T) {
|
|
176
|
+
orphan := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return "" }})
|
|
177
|
+
if got := complete(t, orphan, "@re", 3).Items; len(got) != 0 {
|
|
178
|
+
t.Fatalf("items = %v", got)
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
twin.Run(t, "completions", "caps how many items it returns", func(t *testing.T) {
|
|
183
|
+
crowded := t.TempDir()
|
|
184
|
+
for i := 0; i < 40; i++ {
|
|
185
|
+
touch(t, filepath.Join(crowded, fmt.Sprintf("file-%d.txt", i)))
|
|
186
|
+
}
|
|
187
|
+
capped := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return crowded }, MaxItems: 5})
|
|
188
|
+
if got := len(complete(t, capped, "@file", 5).Items); got != 5 {
|
|
189
|
+
t.Fatalf("%d items", got)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func TestCompletionsOverTheWire(t *testing.T) {
|
|
195
|
+
workspace := t.TempDir()
|
|
196
|
+
touch(t, filepath.Join(workspace, "notes.md"))
|
|
197
|
+
h := testkit.NewHost(host.Options{CompletionTriggerCharacters: []string{pi.MentionTrigger}})
|
|
198
|
+
h.Serve(host.Capabilities{Completions: pi.NewCompletionService(pi.CompletionServiceOptions{
|
|
199
|
+
WorkingDirectoryFor: func(channel string) string {
|
|
200
|
+
if channel == completionsChat {
|
|
201
|
+
return workspace
|
|
202
|
+
}
|
|
203
|
+
return ""
|
|
204
|
+
},
|
|
205
|
+
})})
|
|
206
|
+
client := testkit.Connect(t, h)
|
|
207
|
+
t.Cleanup(client.Close)
|
|
208
|
+
initialization := client.Initialize("completions-client", nil)
|
|
209
|
+
params := func(channel string) obj {
|
|
210
|
+
p := obj{"kind": "userMessage", "text": "@not", "offset": 4}
|
|
211
|
+
if channel != "" {
|
|
212
|
+
p["channel"] = channel
|
|
213
|
+
}
|
|
214
|
+
return p
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
twin.Run(t, "completions", "advertises only the trigger it can answer", func(t *testing.T) {
|
|
218
|
+
// "/" is deliberately absent: Pi's slash commands attach nothing, and every completion
|
|
219
|
+
// item must carry an attachment.
|
|
220
|
+
sameJSON(t, initialization.CompletionTriggerCharacters, []string{"@"}, "triggers")
|
|
221
|
+
testkit.AssertValid(t, "commands", "InitializeResult", initialization)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
twin.Run(t, "completions", "serves a schema-conforming result", func(t *testing.T) {
|
|
225
|
+
p := params(completionsChat)
|
|
226
|
+
p["text"], p["offset"] = "see @not", 8
|
|
227
|
+
raw := client.Must("completions", p)
|
|
228
|
+
var result struct{ Items []obj }
|
|
229
|
+
if err := json.Unmarshal(raw, &result); err != nil {
|
|
230
|
+
t.Fatal(err)
|
|
231
|
+
}
|
|
232
|
+
if len(result.Items) != 1 || result.Items[0]["insertText"] != "@notes.md" {
|
|
233
|
+
t.Fatalf("result = %s", raw)
|
|
234
|
+
}
|
|
235
|
+
testkit.AssertValid(t, "commands", "CompletionsResult", testkit.Normalize(t, raw))
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
twin.Run(t, "completions", "rejects non-chat completion targets", func(t *testing.T) {
|
|
239
|
+
for _, channel := range []string{"", "ahp-session:/completions"} {
|
|
240
|
+
client.ExpectError("completions", params(channel), wire.CodeInvalidParams)
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
twin.Run(t, "completions", "maps provider-alias completion targets onto the default chat", func(t *testing.T) {
|
|
245
|
+
vscode := testkit.Connect(t, h)
|
|
246
|
+
t.Cleanup(vscode.Close)
|
|
247
|
+
vscode.Initialize("vscode-completions-client", obj{"clientInfo": obj{"name": "vscode-editor-window"}})
|
|
248
|
+
for _, c := range []*testkit.Client{vscode, client} {
|
|
249
|
+
var result struct{ Items []obj }
|
|
250
|
+
c.Decode(c.Must("completions", params("pi:/completions")), &result)
|
|
251
|
+
if len(result.Items) != 1 || result.Items[0]["insertText"] != "@notes.md" {
|
|
252
|
+
t.Fatalf("result = %v", result.Items)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func TestCompletionsWithoutAHandler(t *testing.T) {
|
|
259
|
+
twin.Run(t, "completions", "answers with an empty list rather than MethodNotFound", func(t *testing.T) {
|
|
260
|
+
// The client debounces keystrokes into this call; an error per keypress would be worse
|
|
261
|
+
// than nothing to show.
|
|
262
|
+
h := testkit.NewHost(host.Options{})
|
|
263
|
+
client := testkit.Connect(t, h)
|
|
264
|
+
t.Cleanup(client.Close)
|
|
265
|
+
client.Initialize("bare-client", nil)
|
|
266
|
+
var result struct{ Items []obj }
|
|
267
|
+
client.Decode(client.Must("completions", obj{"kind": "userMessage", "channel": completionsChat, "text": "@x", "offset": 2}), &result)
|
|
268
|
+
if len(result.Items) != 0 {
|
|
269
|
+
t.Fatalf("items = %v", result.Items)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
11
|
+
|
|
12
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Twins of upstream test/project-trust.test.ts and test/session-config.test.ts.
|
|
20
|
+
//
|
|
21
|
+
// Project trust is a security default, not a convenience: Pi's SDK trusts a working directory
|
|
22
|
+
// unless told otherwise, and a host takes that directory from a client over the network.
|
|
23
|
+
|
|
24
|
+
func projectWithResources(t *testing.T) string {
|
|
25
|
+
t.Helper()
|
|
26
|
+
dir := t.TempDir()
|
|
27
|
+
// A project-level extension is exactly the thing trust gates: loading it executes code from
|
|
28
|
+
// the directory.
|
|
29
|
+
if err := os.MkdirAll(filepath.Join(dir, ".pi", "extensions"), 0o755); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
if err := os.WriteFile(filepath.Join(dir, ".pi", "extensions", "ext.ts"), []byte("export default () => {};\n"), 0o644); err != nil {
|
|
33
|
+
t.Fatal(err)
|
|
34
|
+
}
|
|
35
|
+
return dir
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func resolveTrust(t *testing.T, cwd string, policy pi.ProjectTrustPolicy, agentDir string) pi.TrustDecision {
|
|
39
|
+
t.Helper()
|
|
40
|
+
// An isolated HOME so a developer's own ~/.agents/skills cannot leak into the result.
|
|
41
|
+
t.Setenv("HOME", t.TempDir())
|
|
42
|
+
d, err := pi.ResolveProjectTrust(cwd, policy, agentDir)
|
|
43
|
+
if err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
return d
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestProjectTrust(t *testing.T) {
|
|
50
|
+
bare, withResources := t.TempDir(), projectWithResources(t)
|
|
51
|
+
agentDir := func() string { return t.TempDir() } // every policy case starts without a recorded decision
|
|
52
|
+
tp := func(b bool) *bool { return &b }
|
|
53
|
+
|
|
54
|
+
twin.Run(t, "project-trust", "trusts by default, matching raw pi SDK construction", func(t *testing.T) {
|
|
55
|
+
if got := resolveTrust(t, withResources, "", agentDir()); got != (pi.TrustDecision{Trusted: true, Reason: "policy"}) {
|
|
56
|
+
t.Fatalf("got %+v", got)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
twin.Run(t, "project-trust", "trusts a directory with nothing to gate", func(t *testing.T) {
|
|
61
|
+
if got := resolveTrust(t, bare, pi.TrustPolicyInherit, agentDir()); got != (pi.TrustDecision{Trusted: true, Reason: "no-project-resources"}) {
|
|
62
|
+
t.Fatalf("got %+v", got)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
twin.Run(t, "project-trust", "declines a project nobody has approved, under `inherit`", func(t *testing.T) {
|
|
67
|
+
// The important case: the SDK's own default would return true here.
|
|
68
|
+
if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, agentDir()); got != (pi.TrustDecision{Trusted: false, Reason: "unknown-project"}) {
|
|
69
|
+
t.Fatalf("got %+v", got)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
twin.Run(t, "project-trust", "inherits a decision the user made with pi's CLI", func(t *testing.T) {
|
|
74
|
+
dir := agentDir()
|
|
75
|
+
if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(true)); err != nil {
|
|
76
|
+
t.Fatal(err)
|
|
77
|
+
}
|
|
78
|
+
// Reusing Pi's store means trusting once covers both tools, and revoking in either
|
|
79
|
+
// revokes in both.
|
|
80
|
+
if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, dir); got != (pi.TrustDecision{Trusted: true, Reason: "user-trusted"}) {
|
|
81
|
+
t.Fatalf("got %+v", got)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
twin.Run(t, "project-trust", "honours an explicit distrust from pi's CLI", func(t *testing.T) {
|
|
86
|
+
dir := agentDir()
|
|
87
|
+
if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(false)); err != nil {
|
|
88
|
+
t.Fatal(err)
|
|
89
|
+
}
|
|
90
|
+
if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, dir); got != (pi.TrustDecision{Trusted: false, Reason: "user-untrusted"}) {
|
|
91
|
+
t.Fatalf("got %+v", got)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
twin.Run(t, "project-trust", "the `never` policy overrides a stored trust decision", func(t *testing.T) {
|
|
96
|
+
dir := agentDir()
|
|
97
|
+
if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(true)); err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
if got := resolveTrust(t, withResources, pi.TrustPolicyNever, dir); got != (pi.TrustDecision{Trusted: false, Reason: "policy"}) {
|
|
101
|
+
t.Fatalf("got %+v", got)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
twin.Skip(t, "project-trust", "applies the decision before pi loads project extensions",
|
|
106
|
+
"needs an embedded Pi that loads project extensions from a client-chosen directory; this host serves the live PiG session only and never loads extensions itself (see PORT.md)")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestResolveSessionConfig(t *testing.T) {
|
|
110
|
+
bare, withResources := t.TempDir(), projectWithResources(t)
|
|
111
|
+
t.Setenv("HOME", t.TempDir())
|
|
112
|
+
service := pi.NewSessionConfigService(pi.SessionConfigOptions{DefaultWorkingDirectory: bare, AgentDir: t.TempDir()})
|
|
113
|
+
resolve := func(p ahptypes.ResolveSessionConfigParams) ahptypes.ResolveSessionConfigResult {
|
|
114
|
+
t.Helper()
|
|
115
|
+
r, err := service.Resolve(context.Background(), p)
|
|
116
|
+
if err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
return r
|
|
120
|
+
}
|
|
121
|
+
dir := func(path string) *string { u := wire.PathToFileURI(path); return &u }
|
|
122
|
+
|
|
123
|
+
twin.Run(t, "session-config", "returns a schema-conforming result", func(t *testing.T) {
|
|
124
|
+
testkit.AssertValid(t, "commands", "ResolveSessionConfigResult", resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel}))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
twin.Run(t, "session-config", "reports what a directory with no project resources would do", func(t *testing.T) {
|
|
128
|
+
result := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(bare)})
|
|
129
|
+
if string(result.Values[pi.ProjectTrustKey]) != "true" {
|
|
130
|
+
t.Fatalf("values = %v", result.Values)
|
|
131
|
+
}
|
|
132
|
+
if p := result.Schema.Properties[pi.ProjectTrustKey]; p.ReadOnly == nil || !*p.ReadOnly {
|
|
133
|
+
t.Fatalf("property = %+v", p)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
twin.Run(t, "session-config", "re-resolves against whatever directory the client is asking about", func(t *testing.T) {
|
|
138
|
+
// The exchange is iterative: the answer changes as the user picks a directory, which is
|
|
139
|
+
// the whole reason the command exists.
|
|
140
|
+
a := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(bare)})
|
|
141
|
+
b := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(withResources)})
|
|
142
|
+
if *a.Schema.Properties[pi.ProjectTrustKey].Description == *b.Schema.Properties[pi.ProjectTrustKey].Description {
|
|
143
|
+
t.Fatal("the description must change with the directory")
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
twin.Run(t, "session-config", "offers no dynamic completions", func(t *testing.T) {
|
|
148
|
+
result, err := service.Completions(context.Background(), ahptypes.SessionConfigCompletionsParams{Channel: wire.RootChannel, Property: pi.ProjectTrustKey})
|
|
149
|
+
if err != nil || len(result.Items) != 0 {
|
|
150
|
+
t.Fatalf("items = %v (%v)", result.Items, err)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// selectionBackend records the model selection each turn ran with.
|
|
156
|
+
type selectionBackend struct {
|
|
157
|
+
*recordingBackend
|
|
158
|
+
mu2 chan struct{}
|
|
159
|
+
selections []ahptypes.ModelSelection
|
|
160
|
+
current ahptypes.ModelSelection
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func newSelectionBackend() *selectionBackend {
|
|
164
|
+
return &selectionBackend{
|
|
165
|
+
recordingBackend: &recordingBackend{}, mu2: make(chan struct{}, 1),
|
|
166
|
+
current: ahptypes.ModelSelection{Id: "default-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"medium"`)}},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func (b *selectionBackend) SelectModel(_ context.Context, s ahptypes.ModelSelection) error {
|
|
171
|
+
b.recordingBackend.mu.Lock()
|
|
172
|
+
defer b.recordingBackend.mu.Unlock()
|
|
173
|
+
b.selections = append(b.selections, s)
|
|
174
|
+
b.current = s
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func (b *selectionBackend) CurrentSelection() *ahptypes.ModelSelection {
|
|
179
|
+
b.recordingBackend.mu.Lock()
|
|
180
|
+
defer b.recordingBackend.mu.Unlock()
|
|
181
|
+
c := b.current
|
|
182
|
+
return &c
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func (b *selectionBackend) selectionCount() int {
|
|
186
|
+
b.recordingBackend.mu.Lock()
|
|
187
|
+
defer b.recordingBackend.mu.Unlock()
|
|
188
|
+
return len(b.selections)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func TestModelSelection(t *testing.T) {
|
|
192
|
+
backend := newSelectionBackend()
|
|
193
|
+
h := testkit.NewHost(host.Options{})
|
|
194
|
+
reg := pi.NewRegistry(pi.RegistryOptions{
|
|
195
|
+
Host: h, CreateSessionManager: pi.InMemoryStorage,
|
|
196
|
+
CreateBackend: func(*pi.LiveSession) (pi.Backend, error) { return backend, nil },
|
|
197
|
+
// The backend is authoritative once it starts, including config values.
|
|
198
|
+
DefaultSelection: func() *ahptypes.ModelSelection {
|
|
199
|
+
return &ahptypes.ModelSelection{Id: "default-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"low"`)}}
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
h.Serve(host.Capabilities{Sessions: reg})
|
|
203
|
+
client := testkit.Connect(t, h)
|
|
204
|
+
t.Cleanup(client.Close)
|
|
205
|
+
client.Initialize("model-client", nil)
|
|
206
|
+
id := newID()
|
|
207
|
+
chat := wire.ChatURI(id)
|
|
208
|
+
client.Must("createSession", obj{"channel": wire.SessionURI(id)})
|
|
209
|
+
client.Subscribe(chat)
|
|
210
|
+
draft := func() *ahptypes.Message { return h.Store().Chat(chat).Draft }
|
|
211
|
+
testkit.Eventually(t, "the backend model to reach the chat draft", func() bool {
|
|
212
|
+
d := draft()
|
|
213
|
+
return d != nil && d.Model != nil && d.Model.Id == "default-model" && string(d.Model.Config[pi.ThinkingConfigKey]) == `"medium"`
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
twin.Run(t, "session-config", "has a model selected before the agent has even started", func(t *testing.T) {
|
|
217
|
+
// Starting an agent takes seconds. A client that subscribes in the meantime would
|
|
218
|
+
// otherwise find an empty picker and be unable to send.
|
|
219
|
+
h2 := testkit.NewHost(host.Options{})
|
|
220
|
+
registry := pi.NewRegistry(pi.RegistryOptions{
|
|
221
|
+
Host: h2, CreateSessionManager: pi.InMemoryStorage,
|
|
222
|
+
DefaultSelection: func() *ahptypes.ModelSelection {
|
|
223
|
+
return &ahptypes.ModelSelection{Id: "seeded-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"medium"`)}}
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
id := newID()
|
|
227
|
+
if err := registry.Create(context.Background(), ahptypes.CreateSessionParams{Channel: wire.SessionURI(id)}); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if d := h2.Store().Chat(wire.ChatURI(id)).Draft; d == nil || d.Model == nil || d.Model.Id != "seeded-model" {
|
|
231
|
+
t.Fatalf("draft = %+v", d)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
twin.Run(t, "session-config", "publishes the model and config actually in effect as the chat draft", func(t *testing.T) {
|
|
236
|
+
// There is no protocol field for "the default model"; a client initialises its input from
|
|
237
|
+
// draft, so this is how a host answers. The backend must also correct a stale config for
|
|
238
|
+
// the same model id.
|
|
239
|
+
d := draft()
|
|
240
|
+
if d.Model.Id != "default-model" || string(d.Model.Config[pi.ThinkingConfigKey]) != `"medium"` {
|
|
241
|
+
t.Fatalf("draft model = %+v", d.Model)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
twin.Run(t, "session-config", "applies the model a client picked, before the prompt runs", func(t *testing.T) {
|
|
246
|
+
promptsBefore, selectionsBefore := len(backend.promptList()), backend.selectionCount()
|
|
247
|
+
message := userMessage("hi")
|
|
248
|
+
message["model"] = obj{"id": "picked-model", "config": obj{pi.ThinkingConfigKey: "high"}}
|
|
249
|
+
client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "t-model", "startedAt": "2025-01-01T00:00:00.000Z", "message": message})
|
|
250
|
+
testkit.Eventually(t, "the prompt to reach the backend", func() bool { return len(backend.promptList()) == promptsBefore+1 })
|
|
251
|
+
if backend.selectionCount() != selectionsBefore+1 {
|
|
252
|
+
t.Fatalf("selections = %d, want %d", backend.selectionCount(), selectionsBefore+1)
|
|
253
|
+
}
|
|
254
|
+
backend.recordingBackend.mu.Lock()
|
|
255
|
+
last := backend.selections[len(backend.selections)-1]
|
|
256
|
+
backend.recordingBackend.mu.Unlock()
|
|
257
|
+
sameJSON(t, last, obj{"id": "picked-model", "config": obj{pi.ThinkingConfigKey: "high"}}, "selection")
|
|
258
|
+
testkit.Eventually(t, "the turn to settle", func() bool { return h.Store().Chat(chat).ActiveTurn == nil })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
twin.Run(t, "session-config", "runs on the current model when the message carries no selection", func(t *testing.T) {
|
|
262
|
+
selectionsBefore, promptsBefore := backend.selectionCount(), len(backend.promptList())
|
|
263
|
+
client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "t-plain", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("again")})
|
|
264
|
+
testkit.Eventually(t, "the prompt to reach the backend", func() bool { return len(backend.promptList()) == promptsBefore+1 })
|
|
265
|
+
if backend.selectionCount() != selectionsBefore {
|
|
266
|
+
t.Fatal("no selection means no switch")
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"os/exec"
|
|
7
|
+
"strings"
|
|
8
|
+
"syscall"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// DeleteResult is the outcome of deleting a session file.
|
|
12
|
+
type DeleteResult struct {
|
|
13
|
+
OK bool
|
|
14
|
+
// Method is "trash", "unlink" or "missing".
|
|
15
|
+
Method string
|
|
16
|
+
Error string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func isMissing(path string) bool {
|
|
20
|
+
_, err := os.Lstat(path)
|
|
21
|
+
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// DeleteSessionFile removes a session file the way Pi's own /resume delete does: through the
|
|
25
|
+
// system trash when there is one, else by unlinking. Success is judged by the file being gone,
|
|
26
|
+
// not by any exit status: a wrapper or broken installation can exit successfully without moving
|
|
27
|
+
// the file, and a trash that moved it may still exit non-zero (port of src/pi/delete-session.ts).
|
|
28
|
+
func DeleteSessionFile(path string) DeleteResult {
|
|
29
|
+
if isMissing(path) {
|
|
30
|
+
return DeleteResult{OK: true, Method: "missing"}
|
|
31
|
+
}
|
|
32
|
+
// "--" guards against a path that looks like a flag.
|
|
33
|
+
args := []string{path}
|
|
34
|
+
if strings.HasPrefix(path, "-") {
|
|
35
|
+
args = []string{"--", path}
|
|
36
|
+
}
|
|
37
|
+
if bin, err := exec.LookPath("trash"); err == nil {
|
|
38
|
+
_ = exec.Command(bin, args...).Run()
|
|
39
|
+
}
|
|
40
|
+
if isMissing(path) {
|
|
41
|
+
return DeleteResult{OK: true, Method: "trash"}
|
|
42
|
+
}
|
|
43
|
+
if err := unlink(path); err != nil {
|
|
44
|
+
// Another process deleting the same session is still the desired outcome.
|
|
45
|
+
if isMissing(path) {
|
|
46
|
+
return DeleteResult{OK: true, Method: "missing"}
|
|
47
|
+
}
|
|
48
|
+
return DeleteResult{OK: false, Method: "unlink", Error: err.Error()}
|
|
49
|
+
}
|
|
50
|
+
return DeleteResult{OK: true, Method: "unlink"}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// unlink removes a file but never a directory (os.Remove would also rmdir an empty one).
|
|
54
|
+
func unlink(path string) error {
|
|
55
|
+
info, err := os.Lstat(path)
|
|
56
|
+
if err != nil {
|
|
57
|
+
return err
|
|
58
|
+
}
|
|
59
|
+
if info.IsDir() {
|
|
60
|
+
return &os.PathError{Op: "unlink", Path: path, Err: errors.New("is a directory")}
|
|
61
|
+
}
|
|
62
|
+
return os.Remove(path)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// FileDeleter adapts DeleteSessionFile to the registry's deletion boundary.
|
|
66
|
+
func FileDeleter(path string) (SessionFileDeletionResult, error) {
|
|
67
|
+
r := DeleteSessionFile(path)
|
|
68
|
+
return SessionFileDeletionResult{OK: r.OK, Error: r.Error}, nil
|
|
69
|
+
}
|