@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,381 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/base64"
|
|
5
|
+
"os"
|
|
6
|
+
"reflect"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
11
|
+
|
|
12
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
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
|
+
// Twins of upstream test/session-lifecycle.test.ts: create, ready, dispose, and the catalogue
|
|
19
|
+
// notifications that keep every client's session list current. The fixture is storage-only (no
|
|
20
|
+
// backend), so readiness is immediate.
|
|
21
|
+
|
|
22
|
+
type lifecycle struct {
|
|
23
|
+
t *testing.T
|
|
24
|
+
h *harness
|
|
25
|
+
workspace string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func newLifecycle(t *testing.T) *lifecycle {
|
|
29
|
+
t.Helper()
|
|
30
|
+
workspace := t.TempDir()
|
|
31
|
+
return &lifecycle{t: t, h: startHarness(t, harnessOptions{workingDir: workspace}), workspace: workspace}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (l *lifecycle) initialized(subscriptions ...string) *testkit.Client {
|
|
35
|
+
l.t.Helper()
|
|
36
|
+
if len(subscriptions) == 0 {
|
|
37
|
+
subscriptions = []string{wire.RootChannel}
|
|
38
|
+
}
|
|
39
|
+
c := testkit.Connect(l.t, l.h.host)
|
|
40
|
+
l.t.Cleanup(c.Close)
|
|
41
|
+
c.Initialize(nextClientID(), obj{"initialSubscriptions": subscriptions})
|
|
42
|
+
return c
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// awaitAction waits for the next action envelope on channel whose action has the given type.
|
|
46
|
+
func awaitAction(t *testing.T, c *testkit.Client, channel, actionType string) (env ahptypes.ActionEnvelope) {
|
|
47
|
+
t.Helper()
|
|
48
|
+
n, ok := c.Await(func(n testkit.Notification) bool {
|
|
49
|
+
e, isAction := n.Envelope()
|
|
50
|
+
if !isAction || e.Channel != channel {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
return actionType == "" || testkit.Normalize(t, e.Action).(map[string]any)["type"] == actionType
|
|
54
|
+
}, testkit.Timeout)
|
|
55
|
+
if !ok {
|
|
56
|
+
t.Fatalf("timed out waiting for %s on %s", actionType, channel)
|
|
57
|
+
}
|
|
58
|
+
env, _ = n.Envelope()
|
|
59
|
+
return env
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func awaitNote(t *testing.T, c *testkit.Client, method string) testkit.Notification {
|
|
63
|
+
t.Helper()
|
|
64
|
+
n, ok := c.Await(func(n testkit.Notification) bool { return n.Method == method }, testkit.Timeout)
|
|
65
|
+
if !ok {
|
|
66
|
+
t.Fatalf("timed out waiting for %s", method)
|
|
67
|
+
}
|
|
68
|
+
return n
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestSessionLifecycle(t *testing.T) {
|
|
72
|
+
twin.Run(t, "session-lifecycle", "creates a session at the client-chosen URI and reports it ready", func(t *testing.T) {
|
|
73
|
+
l := newLifecycle(t)
|
|
74
|
+
c := l.initialized()
|
|
75
|
+
uri := wire.SessionURI(newID())
|
|
76
|
+
c.Must("createSession", obj{"channel": uri, "provider": pi.Provider})
|
|
77
|
+
state := snapshotState[ahptypes.SessionState](t, c, uri)
|
|
78
|
+
if state.Provider != pi.Provider {
|
|
79
|
+
t.Fatalf("provider = %q", state.Provider)
|
|
80
|
+
}
|
|
81
|
+
// Creation is asynchronous in the protocol, but this storage-only fixture has no backend
|
|
82
|
+
// to start, so readiness is immediate.
|
|
83
|
+
testkit.Eventually(t, "the session to be ready", func() bool {
|
|
84
|
+
return l.h.host.Store().Session(uri).Lifecycle == ahptypes.SessionLifecycleReady
|
|
85
|
+
})
|
|
86
|
+
state = *l.h.host.Store().Session(uri)
|
|
87
|
+
if !reflect.DeepEqual(state.WorkingDirectories, []ahptypes.URI{wire.PathToFileURI(l.workspace)}) {
|
|
88
|
+
t.Fatalf("workingDirectories = %v", state.WorkingDirectories)
|
|
89
|
+
}
|
|
90
|
+
// Every session gets exactly one chat, and it is the default. The agent declares no
|
|
91
|
+
// multipleChats capability, which is what tells a client not to call createChat.
|
|
92
|
+
if len(state.Chats) != 1 || state.DefaultChat == nil || *state.DefaultChat != state.Chats[0].Resource {
|
|
93
|
+
t.Fatalf("chats = %+v default %v", state.Chats, state.DefaultChat)
|
|
94
|
+
}
|
|
95
|
+
testkit.AssertValid(t, "state", "SessionState", state)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
twin.Run(t, "session-lifecycle", "uses the first user message as an unnamed session's display title", func(t *testing.T) {
|
|
99
|
+
l := newLifecycle(t)
|
|
100
|
+
c := l.initialized()
|
|
101
|
+
id := newID()
|
|
102
|
+
uri, chat := wire.SessionURI(id), wire.ChatURI(id)
|
|
103
|
+
c.Must("createSession", obj{"channel": uri})
|
|
104
|
+
c.Subscribe(uri)
|
|
105
|
+
c.Subscribe(chat)
|
|
106
|
+
message := userMessage(" Review\n auth handling ")
|
|
107
|
+
message["attachments"] = []any{embeddedText("attachment text is not a session title", "context.txt")}
|
|
108
|
+
c.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "title-turn", "startedAt": "2025-01-01T00:00:00.000Z", "message": message})
|
|
109
|
+
env := awaitAction(t, c, uri, "session/titleChanged")
|
|
110
|
+
c.Ping()
|
|
111
|
+
sameJSON(t, env.Action, obj{"type": "session/titleChanged", "title": "Review auth handling"}, "title action")
|
|
112
|
+
if env.Origin != nil {
|
|
113
|
+
t.Fatal("origin must be absent")
|
|
114
|
+
}
|
|
115
|
+
session := l.h.host.Store().Session(uri)
|
|
116
|
+
if session.Title != "Review auth handling" || session.Chats[0].Title != "Review auth handling" || l.h.host.Store().Chat(chat).Title != "Review auth handling" {
|
|
117
|
+
t.Fatalf("titles: %q %q %q", session.Title, session.Chats[0].Title, l.h.host.Store().Chat(chat).Title)
|
|
118
|
+
}
|
|
119
|
+
live, _ := l.h.services.Registry.Get(uri)
|
|
120
|
+
if name := live.SessionManager.SessionName(); name != "" {
|
|
121
|
+
t.Fatalf("the fallback title must not be persisted as a session name: %q", name)
|
|
122
|
+
}
|
|
123
|
+
var listed struct{ Items []obj }
|
|
124
|
+
c.Decode(c.Must("listSessions", obj{"channel": wire.RootChannel}), &listed)
|
|
125
|
+
found := false
|
|
126
|
+
for _, item := range listed.Items {
|
|
127
|
+
if item["resource"] == uri {
|
|
128
|
+
found = item["title"] == "Review auth handling"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if !found {
|
|
132
|
+
t.Fatalf("listing = %v", listed.Items)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
twin.Run(t, "session-lifecycle", "uses pi's session id as the URI's uuid", func(t *testing.T) {
|
|
137
|
+
l := newLifecycle(t)
|
|
138
|
+
c := l.initialized()
|
|
139
|
+
id := newID()
|
|
140
|
+
c.Must("createSession", obj{"channel": wire.SessionURI(id)})
|
|
141
|
+
live, ok := l.h.services.Registry.Get(wire.SessionURI(id))
|
|
142
|
+
if !ok {
|
|
143
|
+
t.Fatal("no live session")
|
|
144
|
+
}
|
|
145
|
+
// One identity space, so no persistent uuid -> session-file mapping.
|
|
146
|
+
if live.SessionID != id {
|
|
147
|
+
t.Fatalf("sessionId = %q", live.SessionID)
|
|
148
|
+
}
|
|
149
|
+
if got := live.SessionManager.File(); !strings.HasSuffix(got, "_"+id+".jsonl") {
|
|
150
|
+
t.Fatalf("session file = %q", got)
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
twin.Run(t, "session-lifecycle", "announces the new session on the root channel", func(t *testing.T) {
|
|
155
|
+
l := newLifecycle(t)
|
|
156
|
+
c := l.initialized()
|
|
157
|
+
uri := wire.SessionURI(newID())
|
|
158
|
+
c.Must("createSession", obj{"channel": uri})
|
|
159
|
+
n := awaitNote(t, c, "root/sessionAdded")
|
|
160
|
+
var params struct{ Summary ahptypes.SessionSummary }
|
|
161
|
+
c.Decode(n.Params, ¶ms)
|
|
162
|
+
if params.Summary.Resource != uri {
|
|
163
|
+
t.Fatalf("resource = %s", params.Summary.Resource)
|
|
164
|
+
}
|
|
165
|
+
testkit.AssertValid(t, "state", "SessionSummary", params.Summary)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
twin.Run(t, "session-lifecycle", "rejects a duplicate session URI with SessionAlreadyExists", func(t *testing.T) {
|
|
169
|
+
l := newLifecycle(t)
|
|
170
|
+
c := l.initialized()
|
|
171
|
+
uri := wire.SessionURI(newID())
|
|
172
|
+
c.Must("createSession", obj{"channel": uri})
|
|
173
|
+
c.ExpectError("createSession", obj{"channel": uri}, -32003)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
twin.Run(t, "session-lifecycle", "rejects an unknown provider with ProviderNotFound", func(t *testing.T) {
|
|
177
|
+
l := newLifecycle(t)
|
|
178
|
+
c := l.initialized()
|
|
179
|
+
c.ExpectError("createSession", obj{"channel": wire.SessionURI(newID()), "provider": "claude"}, -32002)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
twin.Run(t, "session-lifecycle", "honours a client-supplied working directory", func(t *testing.T) {
|
|
183
|
+
l := newLifecycle(t)
|
|
184
|
+
c := l.initialized()
|
|
185
|
+
other := t.TempDir() + "/pi ahp cwd"
|
|
186
|
+
if err := os.MkdirAll(other, 0o755); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
uri := wire.SessionURI(newID())
|
|
190
|
+
workingDirectory := wire.PathToFileURI(other)
|
|
191
|
+
c.Must("createSession", obj{"channel": uri, "workingDirectories": []string{workingDirectory}})
|
|
192
|
+
if got := l.h.host.Store().Session(uri).WorkingDirectories; !reflect.DeepEqual(got, []ahptypes.URI{workingDirectory}) {
|
|
193
|
+
t.Fatalf("workingDirectories = %v", got)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
twin.Run(t, "session-lifecycle", "tracks the active session count in root state", func(t *testing.T) {
|
|
198
|
+
l := newLifecycle(t)
|
|
199
|
+
c := l.initialized()
|
|
200
|
+
count := func() int64 {
|
|
201
|
+
if a := l.h.host.Store().Root(wire.RootChannel).ActiveSessions; a != nil {
|
|
202
|
+
return *a
|
|
203
|
+
}
|
|
204
|
+
return 0
|
|
205
|
+
}
|
|
206
|
+
before := count()
|
|
207
|
+
c.Must("createSession", obj{"channel": wire.SessionURI(newID())})
|
|
208
|
+
if after := count(); after != before+1 {
|
|
209
|
+
t.Fatalf("activeSessions %d -> %d", before, after)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
twin.Run(t, "session-lifecycle", "disposes a session, drops its channel, and announces the removal", func(t *testing.T) {
|
|
214
|
+
l := newLifecycle(t)
|
|
215
|
+
c := l.initialized()
|
|
216
|
+
id := newID()
|
|
217
|
+
uri, chat := wire.SessionURI(id), wire.ChatURI(id)
|
|
218
|
+
c.Must("createSession", obj{"channel": uri})
|
|
219
|
+
c.Subscribe(uri)
|
|
220
|
+
c.Subscribe(chat)
|
|
221
|
+
if !l.h.host.Store().Has(uri) || l.h.host.SubscriberCount(uri) != 1 || l.h.host.SubscriberCount(chat) != 1 {
|
|
222
|
+
t.Fatal("the session is not set up")
|
|
223
|
+
}
|
|
224
|
+
c.Must("disposeSession", obj{"channel": uri})
|
|
225
|
+
n := awaitNote(t, c, "root/sessionRemoved")
|
|
226
|
+
var params struct{ Session string }
|
|
227
|
+
c.Decode(n.Params, ¶ms)
|
|
228
|
+
if params.Session != uri {
|
|
229
|
+
t.Fatalf("removed = %s", params.Session)
|
|
230
|
+
}
|
|
231
|
+
if l.h.host.Store().Has(uri) || l.h.services.Registry.Has(uri) || l.h.host.SubscriberCount(uri) != 0 || l.h.host.SubscriberCount(chat) != 0 {
|
|
232
|
+
t.Fatal("the session was not fully removed")
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
twin.Run(t, "session-lifecycle", "does not let session disposal target another channel kind", func(t *testing.T) {
|
|
237
|
+
l := newLifecycle(t)
|
|
238
|
+
c := l.initialized()
|
|
239
|
+
c.ExpectError("disposeSession", obj{"channel": wire.RootChannel}, wire.CodeInvalidParams)
|
|
240
|
+
if !l.h.host.Store().Has(wire.RootChannel) {
|
|
241
|
+
t.Fatal("the root channel was disposed")
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
twin.Run(t, "session-lifecycle", "rejects disposing a session that does not exist", func(t *testing.T) {
|
|
246
|
+
l := newLifecycle(t)
|
|
247
|
+
c := l.initialized()
|
|
248
|
+
c.ExpectError("disposeSession", obj{"channel": wire.SessionURI("nope")}, -32001)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
twin.Run(t, "session-lifecycle", "accepts session/titleChanged from a client", func(t *testing.T) {
|
|
252
|
+
l := newLifecycle(t)
|
|
253
|
+
c := l.initialized()
|
|
254
|
+
uri := wire.SessionURI(newID())
|
|
255
|
+
c.Must("createSession", obj{"channel": uri})
|
|
256
|
+
c.Subscribe(uri)
|
|
257
|
+
c.Dispatch(uri, obj{"type": "session/titleChanged", "title": "Refactor auth"})
|
|
258
|
+
// Wait for the echo so the reducer has run before asserting.
|
|
259
|
+
awaitAction(t, c, uri, "session/titleChanged")
|
|
260
|
+
if got := l.h.host.Store().Session(uri).Title; got != "Refactor auth" {
|
|
261
|
+
t.Fatalf("title = %q", got)
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
twin.Run(t, "session-lifecycle", "applies VS Code's chat-addressed rename to the owning session", func(t *testing.T) {
|
|
266
|
+
l := newLifecycle(t)
|
|
267
|
+
c := testkit.Connect(t, l.h.host)
|
|
268
|
+
t.Cleanup(c.Close)
|
|
269
|
+
c.Initialize(nextClientID(), obj{"clientInfo": obj{"name": "vscode-editor-window"}})
|
|
270
|
+
id := newID()
|
|
271
|
+
clientSession := "pi:/" + id
|
|
272
|
+
clientChat := "ahp-chat://default/" + base64.RawURLEncoding.EncodeToString([]byte(clientSession))
|
|
273
|
+
c.Must("createSession", obj{"channel": clientSession})
|
|
274
|
+
c.Subscribe(clientSession)
|
|
275
|
+
c.Dispatch(clientChat, obj{"type": "session/titleChanged", "title": "Renamed from VS Code"})
|
|
276
|
+
// session/ready may land first: the registry finishes creating on its own goroutine.
|
|
277
|
+
env := awaitAction(t, c, clientSession, "session/titleChanged")
|
|
278
|
+
if env.Channel != clientSession {
|
|
279
|
+
t.Fatalf("channel = %s", env.Channel)
|
|
280
|
+
}
|
|
281
|
+
sameJSON(t, env.Action, obj{"type": "session/titleChanged", "title": "Renamed from VS Code"}, "action")
|
|
282
|
+
if env.RejectionReason != nil {
|
|
283
|
+
t.Fatalf("rejected: %s", *env.RejectionReason)
|
|
284
|
+
}
|
|
285
|
+
if got := l.h.host.Store().Session(wire.SessionURI(id)).Title; got != "Renamed from VS Code" {
|
|
286
|
+
t.Fatalf("title = %q", got)
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
twin.Run(t, "session-lifecycle", "declines VS Code's active client without blocking session creation", func(t *testing.T) {
|
|
291
|
+
l := newLifecycle(t)
|
|
292
|
+
clientID := nextClientID()
|
|
293
|
+
c := testkit.Connect(t, l.h.host)
|
|
294
|
+
t.Cleanup(c.Close)
|
|
295
|
+
c.Initialize(clientID, obj{"clientInfo": obj{"name": "vscode-editor-window"}})
|
|
296
|
+
id := newID()
|
|
297
|
+
uri, clientURI := wire.SessionURI(id), "pi:/"+id
|
|
298
|
+
activeClient := obj{"clientId": clientID, "tools": []any{}}
|
|
299
|
+
// VS Code supplies this eagerly. It is ignored rather than making an otherwise usable
|
|
300
|
+
// session fail.
|
|
301
|
+
c.Must("createSession", obj{"channel": clientURI, "activeClient": activeClient})
|
|
302
|
+
c.Subscribe(clientURI)
|
|
303
|
+
for _, action := range []obj{
|
|
304
|
+
{"type": "session/activeClientSet", "activeClient": activeClient},
|
|
305
|
+
{"type": "session/activeClientRemoved", "clientId": clientID},
|
|
306
|
+
} {
|
|
307
|
+
c.Dispatch(clientURI, action)
|
|
308
|
+
env := awaitAction(t, c, clientURI, action["type"].(string))
|
|
309
|
+
if env.RejectionReason == nil || *env.RejectionReason != "This host does not accept active clients" {
|
|
310
|
+
t.Fatalf("rejectionReason = %v", env.RejectionReason)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if got := l.h.host.Store().Session(uri).ActiveClients; len(got) != 0 {
|
|
314
|
+
t.Fatalf("activeClients = %v", got)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func TestRenamingASession(t *testing.T) {
|
|
320
|
+
renamed := func(t *testing.T, l *lifecycle, uri, title string) {
|
|
321
|
+
t.Helper()
|
|
322
|
+
c := testkit.Connect(t, l.h.host)
|
|
323
|
+
t.Cleanup(c.Close)
|
|
324
|
+
c.Initialize(nextClientID(), nil)
|
|
325
|
+
c.Subscribe(uri)
|
|
326
|
+
seq := c.Dispatch(uri, obj{"type": "session/titleChanged", "title": title})
|
|
327
|
+
c.NextEnvelope(uri, seq)
|
|
328
|
+
c.Ping()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
twin.Run(t, "session-lifecycle", "records the new name on the session", func(t *testing.T) {
|
|
332
|
+
l := newLifecycle(t)
|
|
333
|
+
c := l.initialized()
|
|
334
|
+
uri := wire.SessionURI(newID())
|
|
335
|
+
c.Must("createSession", obj{"channel": uri})
|
|
336
|
+
renamed(t, l, uri, "Refactor auth middleware")
|
|
337
|
+
// The same call Pi's own /resume rename makes, so a session renamed here reads the same
|
|
338
|
+
// from Pi's CLI.
|
|
339
|
+
live, _ := l.h.services.Registry.Get(uri)
|
|
340
|
+
testkit.Eventually(t, "the name to be recorded", func() bool { return live.SessionManager.SessionName() == "Refactor auth middleware" })
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
twin.Run(t, "session-lifecycle", "does not create a file for a session that never got a reply", func(t *testing.T) {
|
|
344
|
+
l := newLifecycle(t)
|
|
345
|
+
c := l.initialized()
|
|
346
|
+
uri := wire.SessionURI(newID())
|
|
347
|
+
c.Must("createSession", obj{"channel": uri})
|
|
348
|
+
renamed(t, l, uri, "Never answered")
|
|
349
|
+
// Pi withholds the file until a session has an assistant message, so an empty
|
|
350
|
+
// conversation leaves nothing behind: renaming does not change that policy, and forcing a
|
|
351
|
+
// write here would litter the disk with empty sessions.
|
|
352
|
+
live, _ := l.h.services.Registry.Get(uri)
|
|
353
|
+
file := live.SessionManager.File()
|
|
354
|
+
if file == "" {
|
|
355
|
+
t.Fatal("no session file path")
|
|
356
|
+
}
|
|
357
|
+
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
|
358
|
+
t.Fatalf("a file exists for an unanswered session: %v", err)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
twin.Run(t, "session-lifecycle", "mirrors the name onto the chat and the catalogue entry", func(t *testing.T) {
|
|
363
|
+
l := newLifecycle(t)
|
|
364
|
+
c := l.initialized()
|
|
365
|
+
id := newID()
|
|
366
|
+
uri := wire.SessionURI(id)
|
|
367
|
+
c.Must("createSession", obj{"channel": uri})
|
|
368
|
+
renamed(t, l, uri, "Ship the release")
|
|
369
|
+
session, chat := l.h.host.Store().Session(uri), l.h.host.Store().Chat(wire.ChatURI(id))
|
|
370
|
+
if session.Title != "Ship the release" || chat.Title != "Ship the release" || session.Chats[0].Title != "Ship the release" {
|
|
371
|
+
t.Fatalf("titles: %q %q %q", session.Title, chat.Title, session.Chats[0].Title)
|
|
372
|
+
}
|
|
373
|
+
// ChatState denormalises its summary fields, so both representations have to move together
|
|
374
|
+
// or a client watching only the session drifts.
|
|
375
|
+
c.Dispatch(wire.ChatURI(id), obj{"type": "chat/turnStarted", "turnId": "after-rename", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("This must not replace the explicit name")})
|
|
376
|
+
c.Ping()
|
|
377
|
+
if got := l.h.host.Store().Session(uri).Title; got != "Ship the release" {
|
|
378
|
+
t.Fatalf("title = %q", got)
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Mapping Pi's model catalogue onto protocol agent/model descriptions (port of src/pi/models.ts).
|
|
11
|
+
|
|
12
|
+
// ThinkingConfigKey is the model-config key that carries the reasoning effort.
|
|
13
|
+
const ThinkingConfigKey = "thinkingLevel"
|
|
14
|
+
|
|
15
|
+
// Model is a Pi model (pi-ai's Model): the fields the mapping reads. It is decoded from the JSON
|
|
16
|
+
// the SDK's model registry returns.
|
|
17
|
+
type Model struct {
|
|
18
|
+
ID string `json:"id"`
|
|
19
|
+
Name string `json:"name"`
|
|
20
|
+
API string `json:"api"`
|
|
21
|
+
Provider string `json:"provider"`
|
|
22
|
+
BaseURL string `json:"baseUrl,omitempty"`
|
|
23
|
+
// Reasoning reports whether the model can think.
|
|
24
|
+
Reasoning bool `json:"reasoning"`
|
|
25
|
+
Input []string `json:"input"`
|
|
26
|
+
// Cost is passed through untouched as pricing metadata.
|
|
27
|
+
Cost json.RawMessage `json:"cost,omitempty"`
|
|
28
|
+
ContextWindow int64 `json:"contextWindow"`
|
|
29
|
+
MaxTokens int64 `json:"maxTokens"`
|
|
30
|
+
// ThinkingLevelMap maps a level to the provider's own value; null declares the level
|
|
31
|
+
// unsupported, and absence is "not declared" (which matters for xhigh and max).
|
|
32
|
+
ThinkingLevelMap map[string]json.RawMessage `json:"thinkingLevelMap,omitempty"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ModelFromMap decodes a model from the generic JSON the SDK hands over.
|
|
36
|
+
func ModelFromMap(m map[string]any) (Model, error) {
|
|
37
|
+
raw, err := json.Marshal(m)
|
|
38
|
+
if err != nil {
|
|
39
|
+
return Model{}, err
|
|
40
|
+
}
|
|
41
|
+
var model Model
|
|
42
|
+
return model, json.Unmarshal(raw, &model)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ModelSelectionID is the wire id of a model: Pi's (provider, id) identity, qualified so two
|
|
46
|
+
// providers offering the same model id stay distinct.
|
|
47
|
+
func ModelSelectionID(provider, id string) string { return provider + "/" + id }
|
|
48
|
+
|
|
49
|
+
// SelectionID is the wire id of the model.
|
|
50
|
+
func (m Model) SelectionID() string { return ModelSelectionID(m.Provider, m.ID) }
|
|
51
|
+
|
|
52
|
+
// FindModelBySelectionID resolves a wire id. The qualified form is authoritative. A bare id
|
|
53
|
+
// (legacy clients) is accepted only when it is unambiguous, or when it names the model already in
|
|
54
|
+
// use, so a client can never silently land on the wrong provider's model.
|
|
55
|
+
func FindModelBySelectionID(models []Model, selectionID string, current *Model) (Model, bool) {
|
|
56
|
+
for _, m := range models {
|
|
57
|
+
if m.SelectionID() == selectionID {
|
|
58
|
+
return m, true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
var legacy []Model
|
|
62
|
+
for _, m := range models {
|
|
63
|
+
if m.ID == selectionID {
|
|
64
|
+
legacy = append(legacy, m)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if len(legacy) == 1 {
|
|
68
|
+
return legacy[0], true
|
|
69
|
+
}
|
|
70
|
+
if current != nil && current.ID == selectionID {
|
|
71
|
+
for _, m := range legacy {
|
|
72
|
+
if m.Provider == current.Provider {
|
|
73
|
+
return m, true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Model{}, false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
var extendedThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
|
|
81
|
+
|
|
82
|
+
// SupportedThinkingLevels is pi-ai's getSupportedThinkingLevels: a model that cannot reason
|
|
83
|
+
// offers only "off"; a level mapped to null is unsupported; xhigh and max are opt-in (they must be
|
|
84
|
+
// mapped to something).
|
|
85
|
+
func SupportedThinkingLevels(m Model) []string {
|
|
86
|
+
if !m.Reasoning {
|
|
87
|
+
return []string{"off"}
|
|
88
|
+
}
|
|
89
|
+
var out []string
|
|
90
|
+
for _, level := range extendedThinkingLevels {
|
|
91
|
+
mapped, declared := m.ThinkingLevelMap[level]
|
|
92
|
+
if declared && strings.TrimSpace(string(mapped)) == "null" {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (level == "xhigh" || level == "max") && !declared {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
out = append(out, level)
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ClampThinkingLevel is pi-ai's clampThinkingLevel: the requested level if supported, else the
|
|
104
|
+
// nearest supported one (higher first, then lower).
|
|
105
|
+
func ClampThinkingLevel(m Model, level string) string {
|
|
106
|
+
available := SupportedThinkingLevels(m)
|
|
107
|
+
has := func(l string) bool {
|
|
108
|
+
for _, a := range available {
|
|
109
|
+
if a == l {
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
if has(level) {
|
|
116
|
+
return level
|
|
117
|
+
}
|
|
118
|
+
index := -1
|
|
119
|
+
for i, l := range extendedThinkingLevels {
|
|
120
|
+
if l == level {
|
|
121
|
+
index = i
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if index == -1 {
|
|
125
|
+
if len(available) > 0 {
|
|
126
|
+
return available[0]
|
|
127
|
+
}
|
|
128
|
+
return "off"
|
|
129
|
+
}
|
|
130
|
+
for i := index; i < len(extendedThinkingLevels); i++ {
|
|
131
|
+
if has(extendedThinkingLevels[i]) {
|
|
132
|
+
return extendedThinkingLevels[i]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for i := index - 1; i >= 0; i-- {
|
|
136
|
+
if has(extendedThinkingLevels[i]) {
|
|
137
|
+
return extendedThinkingLevels[i]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if len(available) > 0 {
|
|
141
|
+
return available[0]
|
|
142
|
+
}
|
|
143
|
+
return "off"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func thinkingConfigSchema(m Model) *ahptypes.ConfigSchema {
|
|
147
|
+
levels := SupportedThinkingLevels(m)
|
|
148
|
+
// A model with only "off" has nothing to configure; omitting the schema keeps the client from
|
|
149
|
+
// rendering a pointless single-option picker.
|
|
150
|
+
if len(levels) <= 1 {
|
|
151
|
+
return nil
|
|
152
|
+
}
|
|
153
|
+
def := levels[0]
|
|
154
|
+
for _, l := range levels {
|
|
155
|
+
if l == "medium" {
|
|
156
|
+
def = "medium"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
enum := make([]json.RawMessage, len(levels))
|
|
160
|
+
labels := make([]string, len(levels))
|
|
161
|
+
for i, l := range levels {
|
|
162
|
+
enum[i], _ = json.Marshal(l)
|
|
163
|
+
labels[i] = strings.ToUpper(l[:1]) + l[1:]
|
|
164
|
+
}
|
|
165
|
+
defRaw := json.RawMessage(mustJSON(def))
|
|
166
|
+
description := "How much reasoning effort the model spends before answering."
|
|
167
|
+
return &ahptypes.ConfigSchema{Type: "object", Properties: map[string]ahptypes.ConfigPropertySchema{
|
|
168
|
+
ThinkingConfigKey: {Type: "string", Title: "Thinking", Description: &description, Default: &defRaw, Enum: enum, EnumLabels: labels},
|
|
169
|
+
}}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func mustJSON(v any) []byte {
|
|
173
|
+
raw, err := json.Marshal(v)
|
|
174
|
+
if err != nil {
|
|
175
|
+
panic(err)
|
|
176
|
+
}
|
|
177
|
+
return raw
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ToSessionModelInfo describes a Pi model to a client.
|
|
181
|
+
func ToSessionModelInfo(m Model) ahptypes.SessionModelInfo {
|
|
182
|
+
vision := false
|
|
183
|
+
for _, in := range m.Input {
|
|
184
|
+
vision = vision || in == "image"
|
|
185
|
+
}
|
|
186
|
+
ctx, out := m.ContextWindow, m.MaxTokens
|
|
187
|
+
info := ahptypes.SessionModelInfo{
|
|
188
|
+
Id: m.SelectionID(), Provider: Provider, Name: m.Name,
|
|
189
|
+
MaxContextWindow: &ctx, MaxOutputTokens: &out, SupportsVision: &vision,
|
|
190
|
+
ConfigSchema: thinkingConfigSchema(m),
|
|
191
|
+
// _meta is the protocol's documented place for provider-specific extras; clients may
|
|
192
|
+
// surface pricing but must not depend on it.
|
|
193
|
+
Meta: map[string]json.RawMessage{"piProvider": mustJSON(m.Provider), "api": mustJSON(m.API)},
|
|
194
|
+
}
|
|
195
|
+
if len(m.Cost) > 0 {
|
|
196
|
+
info.Meta["pricing"] = m.Cost
|
|
197
|
+
}
|
|
198
|
+
return info
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// BuildAgentInfo describes this host's one agent. No capabilities are declared: one chat, one
|
|
202
|
+
// working directory; their absence is what tells a client not to attempt those calls.
|
|
203
|
+
func BuildAgentInfo(models []Model) ahptypes.AgentInfo {
|
|
204
|
+
infos := make([]ahptypes.SessionModelInfo, 0, len(models))
|
|
205
|
+
for _, m := range models {
|
|
206
|
+
infos = append(infos, ToSessionModelInfo(m))
|
|
207
|
+
}
|
|
208
|
+
return ahptypes.AgentInfo{
|
|
209
|
+
Provider: Provider, DisplayName: "pi", Description: "pi coding agent", Models: infos,
|
|
210
|
+
ProtectedResources: []ahptypes.ProtectedResourceMetadata{},
|
|
211
|
+
}
|
|
212
|
+
}
|