@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,209 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"reflect"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
13
|
+
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
17
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
18
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Twins of upstream test/session-hydration.test.ts: opening a session that only exists on disk.
|
|
22
|
+
// The catalogue is backed by Pi's session files, most of which no live host has ever touched;
|
|
23
|
+
// without lazy loading every session the catalogue advertises would answer NotFound on subscribe.
|
|
24
|
+
|
|
25
|
+
func snapshotState[T any](t *testing.T, client *testkit.Client, channel string) T {
|
|
26
|
+
t.Helper()
|
|
27
|
+
result := client.Subscribe(channel)
|
|
28
|
+
if result.Snapshot == nil {
|
|
29
|
+
t.Fatalf("no snapshot for %s", channel)
|
|
30
|
+
}
|
|
31
|
+
raw, _ := json.Marshal(result.Snapshot.State)
|
|
32
|
+
var state T
|
|
33
|
+
if err := json.Unmarshal(raw, &state); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
return state
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestOpeningASessionFromTheCatalogue(t *testing.T) {
|
|
40
|
+
f := startHydrated(t, hydratedOptions{})
|
|
41
|
+
|
|
42
|
+
twin.Run(t, "session-hydration", "hydrates a catalogued session and counts it as active", func(t *testing.T) {
|
|
43
|
+
state := snapshotState[ahptypes.SessionState](t, f.client, f.session())
|
|
44
|
+
if state.Lifecycle != ahptypes.SessionLifecycleReady || len(state.Chats) != 1 {
|
|
45
|
+
t.Fatalf("state = %+v", state)
|
|
46
|
+
}
|
|
47
|
+
if state.DefaultChat == nil || *state.DefaultChat != f.chat() {
|
|
48
|
+
t.Fatalf("defaultChat = %v", state.DefaultChat)
|
|
49
|
+
}
|
|
50
|
+
if !reflect.DeepEqual(state.WorkingDirectories, []ahptypes.URI{wire.PathToFileURI(f.workspace)}) {
|
|
51
|
+
t.Fatalf("workingDirectories = %v", state.WorkingDirectories)
|
|
52
|
+
}
|
|
53
|
+
testkit.AssertValid(t, "state", "SessionState", state)
|
|
54
|
+
if got := f.host.Store().Root(wire.RootChannel).ActiveSessions; got == nil || *got != 1 {
|
|
55
|
+
t.Fatalf("activeSessions = %v", got)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
twin.Run(t, "session-hydration", "rebuilds the transcript onto the chat channel", func(t *testing.T) {
|
|
60
|
+
chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
|
|
61
|
+
if len(chat.Turns) != 2 || chat.Turns[0].Message.Text != "Read note.txt" || chat.Turns[0].State != ahptypes.TurnStateComplete {
|
|
62
|
+
t.Fatalf("turns = %+v", turnShapes(chat.Turns))
|
|
63
|
+
}
|
|
64
|
+
testkit.AssertValid(t, "state", "ChatState", chat)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
twin.Run(t, "session-hydration", "pairs each tool call with the result that followed it", func(t *testing.T) {
|
|
68
|
+
chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
|
|
69
|
+
parts := chat.Turns[0].ResponseParts
|
|
70
|
+
var kinds []ahptypes.ResponsePartKind
|
|
71
|
+
for _, p := range parts {
|
|
72
|
+
raw, _ := json.Marshal(p)
|
|
73
|
+
var k struct{ Kind ahptypes.ResponsePartKind }
|
|
74
|
+
_ = json.Unmarshal(raw, &k)
|
|
75
|
+
kinds = append(kinds, k.Kind)
|
|
76
|
+
}
|
|
77
|
+
want := []ahptypes.ResponsePartKind{ahptypes.ResponsePartKindReasoning, ahptypes.ResponsePartKindToolCall, ahptypes.ResponsePartKindMarkdown}
|
|
78
|
+
if !reflect.DeepEqual(kinds, want) {
|
|
79
|
+
t.Fatalf("kinds = %v", kinds)
|
|
80
|
+
}
|
|
81
|
+
call := parts[1].Value.(*ahptypes.ToolCallResponsePart).ToolCall.Value.(*ahptypes.ToolCallCompletedState)
|
|
82
|
+
if call.Status != ahptypes.ToolCallStatusCompleted || call.ToolName != "read" {
|
|
83
|
+
t.Fatalf("toolCall = %+v", call)
|
|
84
|
+
}
|
|
85
|
+
sameJSON(t, call.Content, []any{obj{"type": "text", "text": "ALPHA"}}, "tool content")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
twin.Run(t, "session-hydration", "still answers NotFound for a session that really does not exist", func(t *testing.T) {
|
|
89
|
+
f.client.ExpectError("subscribe", obj{"channel": wire.SessionURI(newID())}, -32008)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
twin.Run(t, "session-hydration", "restores images stored in pi user messages", func(t *testing.T) {
|
|
93
|
+
fresh := startHydrated(t, hydratedOptions{includeImage: true})
|
|
94
|
+
chat := snapshotState[ahptypes.ChatState](t, fresh.client, fresh.chat())
|
|
95
|
+
sameJSON(t, chat.Turns[0].Message.Attachments, []any{obj{
|
|
96
|
+
"type": "embeddedResource", "label": "Image 1", "displayKind": "image", "data": onePixelPNG, "contentType": "image/png",
|
|
97
|
+
}}, "attachments")
|
|
98
|
+
testkit.AssertValid(t, "state", "ChatState", chat)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
twin.Run(t, "session-hydration", "hydrates from either half of the pair", func(t *testing.T) {
|
|
102
|
+
fresh := startHydrated(t, hydratedOptions{})
|
|
103
|
+
if r := fresh.client.Subscribe(fresh.chat()); r.Snapshot == nil {
|
|
104
|
+
t.Fatal("no snapshot")
|
|
105
|
+
}
|
|
106
|
+
if !fresh.host.Store().Has(fresh.session()) {
|
|
107
|
+
t.Fatal("the session must load alongside its chat")
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
twin.Run(t, "session-hydration", "coalesces concurrent session and chat hydration", func(t *testing.T) {
|
|
112
|
+
source := startHydrated(t, hydratedOptions{})
|
|
113
|
+
catalogue := &blockingCatalogue{inner: pi.NewCatalogue(source.root), started: make(chan struct{}), gate: make(chan struct{})}
|
|
114
|
+
h := testkit.NewHost(host.Options{})
|
|
115
|
+
var mu sync.Mutex
|
|
116
|
+
live := map[string]bool{}
|
|
117
|
+
var adopted []string
|
|
118
|
+
hydrator := pi.NewHydrator(pi.HydratorOptions{
|
|
119
|
+
Host: h, Catalogue: catalogue,
|
|
120
|
+
IsLive: func(s string) bool { mu.Lock(); defer mu.Unlock(); return live[s] },
|
|
121
|
+
IsDisposing: func(string) bool { return false },
|
|
122
|
+
Adopt: func(a pi.AdoptedSession) {
|
|
123
|
+
mu.Lock()
|
|
124
|
+
live[a.URI] = true
|
|
125
|
+
adopted = append(adopted, a.URI)
|
|
126
|
+
mu.Unlock()
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
session, chat := wire.SessionURI(source.sessionID), wire.ChatURI(source.sessionID)
|
|
130
|
+
results := make(chan bool, 2)
|
|
131
|
+
go func() { ok, _ := hydrator.Hydrate(context.Background(), session); results <- ok }()
|
|
132
|
+
<-catalogue.started
|
|
133
|
+
go func() { ok, _ := hydrator.Hydrate(context.Background(), chat); results <- ok }()
|
|
134
|
+
time.Sleep(20 * time.Millisecond) // let the second caller reach the coalescing point
|
|
135
|
+
close(catalogue.gate)
|
|
136
|
+
if a, b := <-results, <-results; !a || !b {
|
|
137
|
+
t.Fatalf("hydrate results = %v %v", a, b)
|
|
138
|
+
}
|
|
139
|
+
if catalogue.lookupCount() != 1 {
|
|
140
|
+
t.Fatalf("lookups = %d", catalogue.lookupCount())
|
|
141
|
+
}
|
|
142
|
+
if !reflect.DeepEqual(adopted, []string{session}) || !h.Store().Has(session) || !h.Store().Has(chat) {
|
|
143
|
+
t.Fatalf("adopted %v, session %v chat %v", adopted, h.Store().Has(session), h.Store().Has(chat))
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
twin.Run(t, "session-hydration", "preserves catalogue modifiedAt when a disk session becomes a live overlay", func(t *testing.T) {
|
|
148
|
+
fixture := startHydrated(t, hydratedOptions{})
|
|
149
|
+
file, err := pi.NewCatalogue(fixture.root).FindSessionFile(fixture.sessionID)
|
|
150
|
+
if err != nil || file == "" {
|
|
151
|
+
t.Fatalf("file %q err %v", file, err)
|
|
152
|
+
}
|
|
153
|
+
stamp := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
|
|
154
|
+
if err := os.Chtimes(file, stamp, stamp); err != nil {
|
|
155
|
+
t.Fatal(err)
|
|
156
|
+
}
|
|
157
|
+
iso := "2025-06-01T12:00:00.000Z"
|
|
158
|
+
listItems := func() []obj {
|
|
159
|
+
var r struct{ Items []obj }
|
|
160
|
+
fixture.client.Decode(fixture.client.Must("listSessions", obj{"channel": wire.RootChannel}), &r)
|
|
161
|
+
return r.Items
|
|
162
|
+
}
|
|
163
|
+
if got := listItems(); len(got) != 1 || got[0]["modifiedAt"] != iso {
|
|
164
|
+
t.Fatalf("before = %v", got)
|
|
165
|
+
}
|
|
166
|
+
chat := snapshotState[ahptypes.ChatState](t, fixture.client, fixture.chat())
|
|
167
|
+
if chat.ModifiedAt != iso {
|
|
168
|
+
t.Fatalf("chat modifiedAt = %s", chat.ModifiedAt)
|
|
169
|
+
}
|
|
170
|
+
if got := listItems(); len(got) != 1 || got[0]["modifiedAt"] != iso {
|
|
171
|
+
t.Fatalf("after = %v", got)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// blockingCatalogue holds the first lookup until the test releases it.
|
|
177
|
+
type blockingCatalogue struct {
|
|
178
|
+
inner *pi.Catalogue
|
|
179
|
+
started chan struct{}
|
|
180
|
+
gate chan struct{}
|
|
181
|
+
once sync.Once
|
|
182
|
+
mu sync.Mutex
|
|
183
|
+
lookups int
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func (b *blockingCatalogue) FindSessionFile(id string) (string, error) {
|
|
187
|
+
b.mu.Lock()
|
|
188
|
+
b.lookups++
|
|
189
|
+
b.mu.Unlock()
|
|
190
|
+
b.once.Do(func() { close(b.started) })
|
|
191
|
+
<-b.gate
|
|
192
|
+
return b.inner.FindSessionFile(id)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func (b *blockingCatalogue) lookupCount() int { b.mu.Lock(); defer b.mu.Unlock(); return b.lookups }
|
|
196
|
+
|
|
197
|
+
// snapshotStateOf decodes the state of an already-received subscribe result.
|
|
198
|
+
func snapshotStateOf[T any](t *testing.T, result ahptypes.SubscribeResult) T {
|
|
199
|
+
t.Helper()
|
|
200
|
+
if result.Snapshot == nil {
|
|
201
|
+
t.Fatal("no snapshot")
|
|
202
|
+
}
|
|
203
|
+
raw, _ := json.Marshal(result.Snapshot.State)
|
|
204
|
+
var state T
|
|
205
|
+
if err := json.Unmarshal(raw, &state); err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
return state
|
|
209
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
|
|
10
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
11
|
+
|
|
12
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/channels"
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// FileFinder locates the file behind a session id ("" when there is none).
|
|
20
|
+
type FileFinder interface {
|
|
21
|
+
FindSessionFile(sessionID string) (string, error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// HydratorOptions configure a [Hydrator].
|
|
25
|
+
type HydratorOptions struct {
|
|
26
|
+
Host *host.Host
|
|
27
|
+
Catalogue FileFinder
|
|
28
|
+
IsLive func(sessionChannel string) bool
|
|
29
|
+
// IsDisposing reports a disposal in flight, which must not be raced by a load.
|
|
30
|
+
IsDisposing func(sessionChannel string) bool
|
|
31
|
+
FallbackSelection func() *ahptypes.ModelSelection
|
|
32
|
+
// Adopt registers the loaded session with the registry, without a backend.
|
|
33
|
+
Adopt func(AdoptedSession)
|
|
34
|
+
// WrapStore may replace the store a session file was opened into (the running session's
|
|
35
|
+
// store reads the host's live mirror instead of the file); nil keeps the file-backed store.
|
|
36
|
+
WrapStore func(sessionID string, opened SessionStore) SessionStore
|
|
37
|
+
Log func(format string, args ...any)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Hydrator loads a session that exists only on disk into protocol state, on first subscription
|
|
41
|
+
// (port of src/pi/session-hydrator.ts). A chat and its session share one id, so either URI loads
|
|
42
|
+
// the pair. The session becomes ready, since its transcript is genuinely available; it simply has
|
|
43
|
+
// no agent attached until someone starts a turn.
|
|
44
|
+
type Hydrator struct {
|
|
45
|
+
opts HydratorOptions
|
|
46
|
+
|
|
47
|
+
mu sync.Mutex
|
|
48
|
+
hydrations map[string]chan struct{}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// NewHydrator creates a hydrator.
|
|
52
|
+
func NewHydrator(opts HydratorOptions) *Hydrator {
|
|
53
|
+
return &Hydrator{opts: opts, hydrations: map[string]chan struct{}{}}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func (h *Hydrator) logf(format string, args ...any) {
|
|
57
|
+
if h.opts.Log != nil {
|
|
58
|
+
h.opts.Log(format, args...)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hydrate makes channel exist in the store if a session file backs it; it reports whether the
|
|
63
|
+
// channel now exists.
|
|
64
|
+
func (h *Hydrator) Hydrate(_ context.Context, channel string) (bool, error) {
|
|
65
|
+
var sessionID string
|
|
66
|
+
var ok bool
|
|
67
|
+
if wire.IsChatChannel(channel) {
|
|
68
|
+
sessionID, ok = wire.ChatIDFromURI(channel)
|
|
69
|
+
} else {
|
|
70
|
+
// The provider alias is explicit: an unknown channel scheme must not become a session
|
|
71
|
+
// merely because its path resembles one.
|
|
72
|
+
sessionID, ok = wire.SessionIDFromURI(channel, Provider)
|
|
73
|
+
}
|
|
74
|
+
if !ok || sessionID == "" {
|
|
75
|
+
return false, nil
|
|
76
|
+
}
|
|
77
|
+
// Register the session under the URI the client actually used, so a non-standard scheme
|
|
78
|
+
// resolves to the same channel it subscribed to.
|
|
79
|
+
session := channel
|
|
80
|
+
if wire.IsChatChannel(channel) {
|
|
81
|
+
session = wire.SessionURI(sessionID)
|
|
82
|
+
}
|
|
83
|
+
if h.opts.IsLive(session) {
|
|
84
|
+
return h.opts.Host.Store().Has(channel), nil
|
|
85
|
+
}
|
|
86
|
+
if h.opts.IsDisposing(session) {
|
|
87
|
+
return false, nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
h.mu.Lock()
|
|
91
|
+
if pending, ok := h.hydrations[sessionID]; ok {
|
|
92
|
+
h.mu.Unlock()
|
|
93
|
+
<-pending
|
|
94
|
+
return h.opts.Host.Store().Has(channel), nil
|
|
95
|
+
}
|
|
96
|
+
done := make(chan struct{})
|
|
97
|
+
h.hydrations[sessionID] = done
|
|
98
|
+
h.mu.Unlock()
|
|
99
|
+
h.hydrateSession(sessionID, session)
|
|
100
|
+
h.mu.Lock()
|
|
101
|
+
delete(h.hydrations, sessionID)
|
|
102
|
+
h.mu.Unlock()
|
|
103
|
+
close(done)
|
|
104
|
+
return h.opts.Host.Store().Has(channel), nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func firstUserText(turns []ahptypes.Turn) string {
|
|
108
|
+
for _, t := range turns {
|
|
109
|
+
if strings.TrimSpace(t.Message.Text) != "" {
|
|
110
|
+
return t.Message.Text
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func (h *Hydrator) hydrateSession(sessionID, session string) {
|
|
117
|
+
chat := wire.ChatURI(sessionID)
|
|
118
|
+
file, err := h.opts.Catalogue.FindSessionFile(sessionID)
|
|
119
|
+
if err != nil {
|
|
120
|
+
h.logf("cannot search for session %s: %v", sessionID, err)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if file == "" {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
manager, err := pisession.Open(file)
|
|
127
|
+
if err != nil {
|
|
128
|
+
h.logf("cannot open %s: %v", file, err)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
turns := RebuildHistoryFromSession(manager, RebuildOptions{TurnIDPrefix: sessionID}).Turns
|
|
133
|
+
// Present only when the window really is a tail: its presence is the protocol's signal that
|
|
134
|
+
// more history can be paged in.
|
|
135
|
+
nextCursor := InitialTurnsCursor(manager, sessionID, turns)
|
|
136
|
+
modifiedAt := nowISO()
|
|
137
|
+
if len(turns) > 0 && turns[len(turns)-1].StartedAt != nil {
|
|
138
|
+
modifiedAt = *turns[len(turns)-1].StartedAt
|
|
139
|
+
}
|
|
140
|
+
if info, err := os.Stat(file); err == nil {
|
|
141
|
+
// The file may disappear after it was located; the reconstructed transcript timestamp
|
|
142
|
+
// remains a valid fallback for this snapshot.
|
|
143
|
+
modifiedAt = info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
|
|
144
|
+
}
|
|
145
|
+
workingDirectory := manager.Cwd()
|
|
146
|
+
title := mapper.SessionDisplayTitle(manager.SessionName(), firstUserText(turns))
|
|
147
|
+
|
|
148
|
+
// Another create/adopt or disposal may have won during file I/O: live state must not be
|
|
149
|
+
// replaced by this disk snapshot.
|
|
150
|
+
if h.opts.IsLive(session) || h.opts.IsDisposing(session) {
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Read, for the same reason the catalogue reports read; the reducer still clears the bit if a
|
|
155
|
+
// turn starts.
|
|
156
|
+
status := ahptypes.SessionStatusIdle | ahptypes.SessionStatusIsRead
|
|
157
|
+
|
|
158
|
+
// Which model this conversation was last using. The agent starts only on the first new turn,
|
|
159
|
+
// so without seeding it here the client's model picker is empty until the user has already
|
|
160
|
+
// sent something. The recorded model is checked field by field: a session file can carry a
|
|
161
|
+
// partially populated entry, and the wire id needs both halves of Pi's (provider, modelId).
|
|
162
|
+
level, provider, modelID := manager.ContextSettings()
|
|
163
|
+
var selection *ahptypes.ModelSelection
|
|
164
|
+
if provider != "" && modelID != "" {
|
|
165
|
+
thinking, _ := json.Marshal(level)
|
|
166
|
+
selection = &ahptypes.ModelSelection{Id: ModelSelectionID(provider, modelID), Config: map[string]json.RawMessage{ThinkingConfigKey: thinking}}
|
|
167
|
+
} else if h.opts.FallbackSelection != nil {
|
|
168
|
+
selection = h.opts.FallbackSelection()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
chatState := &ahptypes.ChatState{Resource: chat, Title: title, Status: status, ModifiedAt: modifiedAt, Turns: turns}
|
|
172
|
+
if nextCursor != "" {
|
|
173
|
+
chatState.TurnsNextCursor = &nextCursor
|
|
174
|
+
}
|
|
175
|
+
if selection != nil {
|
|
176
|
+
chatState.Draft = &ahptypes.Message{Text: "", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}, Model: selection}
|
|
177
|
+
}
|
|
178
|
+
if err := h.opts.Host.Store().Create(chat, chatState); err != nil {
|
|
179
|
+
h.logf("cannot create %s: %v", chat, err)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
fileMeta, _ := json.Marshal(file)
|
|
183
|
+
sessionState := &ahptypes.SessionState{
|
|
184
|
+
Provider: Provider, Title: title, Status: status,
|
|
185
|
+
// The transcript is genuinely available, so the session is ready.
|
|
186
|
+
Lifecycle: ahptypes.SessionLifecycleReady, ActiveClients: []ahptypes.SessionActiveClient{},
|
|
187
|
+
Chats: []ahptypes.ChatSummary{channels.ChatSummaryOf(chatState)}, DefaultChat: &chat,
|
|
188
|
+
Meta: map[string]json.RawMessage{"piSessionFile": fileMeta, "hydrated": json.RawMessage("true")},
|
|
189
|
+
}
|
|
190
|
+
if workingDirectory != "" {
|
|
191
|
+
sessionState.WorkingDirectories = []ahptypes.URI{wire.PathToFileURI(workingDirectory)}
|
|
192
|
+
}
|
|
193
|
+
if err := h.opts.Host.Store().Create(session, sessionState, wire.KindSession); err != nil {
|
|
194
|
+
h.logf("cannot create %s: %v", session, err)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
createdAt := modifiedAt
|
|
198
|
+
if len(turns) > 0 && turns[0].StartedAt != nil {
|
|
199
|
+
createdAt = *turns[0].StartedAt
|
|
200
|
+
}
|
|
201
|
+
var store SessionStore = manager
|
|
202
|
+
if h.opts.WrapStore != nil {
|
|
203
|
+
store = h.opts.WrapStore(sessionID, store)
|
|
204
|
+
}
|
|
205
|
+
if h.opts.Adopt != nil {
|
|
206
|
+
h.opts.Adopt(AdoptedSession{
|
|
207
|
+
URI: session, SessionID: sessionID, WorkingDirectory: workingDirectory, SessionManager: store,
|
|
208
|
+
ChatChannel: chat, CreatedAt: createdAt,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
h.logf("hydrated %s with %d turns", session, len(turns))
|
|
212
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"encoding/binary"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"image"
|
|
10
|
+
"image/color"
|
|
11
|
+
"image/draw"
|
|
12
|
+
"image/gif"
|
|
13
|
+
"image/jpeg"
|
|
14
|
+
"image/png"
|
|
15
|
+
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Port of image-input.ts. Upstream calls Pi's convertToPng / resizeImage (WASM codecs); those are
|
|
20
|
+
// not part of the PiG SDK, so the standard library's PNG, JPEG and GIF codecs plus a small BMP
|
|
21
|
+
// decoder stand in. WebP cannot be decoded here: it passes through unchanged when the header is
|
|
22
|
+
// valid and is never resized (see PORT.md).
|
|
23
|
+
|
|
24
|
+
const (
|
|
25
|
+
maxImageDimension = 2000
|
|
26
|
+
maxImageBytes = 4_500_000
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
var supportedInlineTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
|
|
30
|
+
|
|
31
|
+
// PrepareImagesForPi validates, converts and (when autoResize) bounds client images before they
|
|
32
|
+
// enter the agent. It returns nil for no images.
|
|
33
|
+
func PrepareImagesForPi(images []mapper.Image, autoResize bool) ([]mapper.Image, error) {
|
|
34
|
+
if len(images) == 0 {
|
|
35
|
+
return nil, nil
|
|
36
|
+
}
|
|
37
|
+
out := make([]mapper.Image, 0, len(images))
|
|
38
|
+
for i, img := range images {
|
|
39
|
+
prepared, err := prepareImage(img, autoResize, i)
|
|
40
|
+
if err != nil {
|
|
41
|
+
return nil, err
|
|
42
|
+
}
|
|
43
|
+
out = append(out, prepared)
|
|
44
|
+
}
|
|
45
|
+
return out, nil
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func prepareImage(img mapper.Image, autoResize bool, index int) (mapper.Image, error) {
|
|
49
|
+
data := img.Data
|
|
50
|
+
mimeType := mapper.NormalizeImageMimeType(img.MimeType)
|
|
51
|
+
if mimeType == "" {
|
|
52
|
+
return mapper.Image{}, fmt.Errorf("Image %d has an invalid MIME type: %s", index+1, img.MimeType)
|
|
53
|
+
}
|
|
54
|
+
if !supportedInlineTypes[mimeType] {
|
|
55
|
+
converted, err := convertToPNG(data, mimeType)
|
|
56
|
+
if err != nil {
|
|
57
|
+
return mapper.Image{}, fmt.Errorf("Image %d could not be converted from %s: %w", index+1, mimeType, err)
|
|
58
|
+
}
|
|
59
|
+
data, mimeType = converted, "image/png"
|
|
60
|
+
}
|
|
61
|
+
if !autoResize {
|
|
62
|
+
return mapper.Image{Type: "image", Data: data, MimeType: mimeType}, nil
|
|
63
|
+
}
|
|
64
|
+
resized, resizedType, err := resizeImage(data, mimeType)
|
|
65
|
+
if err != nil {
|
|
66
|
+
return mapper.Image{}, fmt.Errorf("Image %d could not be prepared for inline model input: %w", index+1, err)
|
|
67
|
+
}
|
|
68
|
+
return mapper.Image{Type: "image", Data: resized, MimeType: resizedType}, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func decodeAny(raw []byte, mimeType string) (image.Image, error) {
|
|
72
|
+
switch mimeType {
|
|
73
|
+
case "image/png":
|
|
74
|
+
return png.Decode(bytes.NewReader(raw))
|
|
75
|
+
case "image/jpeg":
|
|
76
|
+
return jpeg.Decode(bytes.NewReader(raw))
|
|
77
|
+
case "image/gif":
|
|
78
|
+
return gif.Decode(bytes.NewReader(raw))
|
|
79
|
+
case "image/bmp", "image/x-ms-bmp":
|
|
80
|
+
return decodeBMP(raw)
|
|
81
|
+
}
|
|
82
|
+
return nil, fmt.Errorf("no decoder for %s", mimeType)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func encodePNG(img image.Image) (string, error) {
|
|
86
|
+
var buf bytes.Buffer
|
|
87
|
+
if err := png.Encode(&buf, img); err != nil {
|
|
88
|
+
return "", err
|
|
89
|
+
}
|
|
90
|
+
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func convertToPNG(data, mimeType string) (string, error) {
|
|
94
|
+
raw, err := base64.StdEncoding.DecodeString(data)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return "", err
|
|
97
|
+
}
|
|
98
|
+
img, err := decodeAny(raw, mimeType)
|
|
99
|
+
if err != nil {
|
|
100
|
+
return "", err
|
|
101
|
+
}
|
|
102
|
+
return encodePNG(img)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func resizeImage(data, mimeType string) (string, string, error) {
|
|
106
|
+
raw, err := base64.StdEncoding.DecodeString(data)
|
|
107
|
+
if err != nil {
|
|
108
|
+
return "", "", err
|
|
109
|
+
}
|
|
110
|
+
if mimeType == "image/webp" {
|
|
111
|
+
if len(raw) < 12 || string(raw[:4]) != "RIFF" || string(raw[8:12]) != "WEBP" {
|
|
112
|
+
return "", "", errors.New("not a WebP image")
|
|
113
|
+
}
|
|
114
|
+
return data, mimeType, nil
|
|
115
|
+
}
|
|
116
|
+
img, err := decodeAny(raw, mimeType)
|
|
117
|
+
if err != nil {
|
|
118
|
+
return "", "", err
|
|
119
|
+
}
|
|
120
|
+
b := img.Bounds()
|
|
121
|
+
if b.Dx() <= maxImageDimension && b.Dy() <= maxImageDimension && len(raw) <= maxImageBytes {
|
|
122
|
+
return data, mimeType, nil
|
|
123
|
+
}
|
|
124
|
+
scale := 1.0
|
|
125
|
+
if b.Dx() > maxImageDimension {
|
|
126
|
+
scale = float64(maxImageDimension) / float64(b.Dx())
|
|
127
|
+
}
|
|
128
|
+
if s := float64(maxImageDimension) / float64(b.Dy()); s < scale {
|
|
129
|
+
scale = s
|
|
130
|
+
}
|
|
131
|
+
w, h := max(1, int(float64(b.Dx())*scale)), max(1, int(float64(b.Dy())*scale))
|
|
132
|
+
for {
|
|
133
|
+
small := boxResize(img, w, h)
|
|
134
|
+
if mimeType == "image/jpeg" {
|
|
135
|
+
var buf bytes.Buffer
|
|
136
|
+
if err := jpeg.Encode(&buf, small, &jpeg.Options{Quality: 85}); err != nil {
|
|
137
|
+
return "", "", err
|
|
138
|
+
}
|
|
139
|
+
if buf.Len() <= maxImageBytes || w <= 1 {
|
|
140
|
+
return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/jpeg", nil
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
encoded, err := encodePNG(small)
|
|
144
|
+
if err != nil {
|
|
145
|
+
return "", "", err
|
|
146
|
+
}
|
|
147
|
+
if base64.StdEncoding.DecodedLen(len(encoded)) <= maxImageBytes || w <= 1 {
|
|
148
|
+
return encoded, "image/png", nil
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
w, h = max(1, w*3/4), max(1, h*3/4)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// boxResize averages source pixels into each destination pixel.
|
|
156
|
+
func boxResize(src image.Image, w, h int) image.Image {
|
|
157
|
+
b := src.Bounds()
|
|
158
|
+
dst := image.NewNRGBA(image.Rect(0, 0, w, h))
|
|
159
|
+
for y := 0; y < h; y++ {
|
|
160
|
+
y0, y1 := b.Min.Y+y*b.Dy()/h, b.Min.Y+(y+1)*b.Dy()/h
|
|
161
|
+
if y1 <= y0 {
|
|
162
|
+
y1 = y0 + 1
|
|
163
|
+
}
|
|
164
|
+
for x := 0; x < w; x++ {
|
|
165
|
+
x0, x1 := b.Min.X+x*b.Dx()/w, b.Min.X+(x+1)*b.Dx()/w
|
|
166
|
+
if x1 <= x0 {
|
|
167
|
+
x1 = x0 + 1
|
|
168
|
+
}
|
|
169
|
+
var r, g, bl, a, n uint64
|
|
170
|
+
for yy := y0; yy < y1; yy++ {
|
|
171
|
+
for xx := x0; xx < x1; xx++ {
|
|
172
|
+
pr, pg, pb, pa := src.At(xx, yy).RGBA()
|
|
173
|
+
r, g, bl, a, n = r+uint64(pr), g+uint64(pg), bl+uint64(pb), a+uint64(pa), n+1
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
dst.Set(x, y, color.RGBA64{uint16(r / n), uint16(g / n), uint16(bl / n), uint16(a / n)})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return dst
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// decodeBMP reads uncompressed 24- and 32-bit BMPs (BITMAPINFOHEADER), the shapes clients send.
|
|
183
|
+
func decodeBMP(raw []byte) (image.Image, error) {
|
|
184
|
+
if len(raw) < 54 || raw[0] != 'B' || raw[1] != 'M' {
|
|
185
|
+
return nil, errors.New("not a BMP")
|
|
186
|
+
}
|
|
187
|
+
offset := int(binary.LittleEndian.Uint32(raw[10:14]))
|
|
188
|
+
width := int(int32(binary.LittleEndian.Uint32(raw[18:22])))
|
|
189
|
+
height := int(int32(binary.LittleEndian.Uint32(raw[22:26])))
|
|
190
|
+
bpp := int(binary.LittleEndian.Uint16(raw[28:30]))
|
|
191
|
+
compression := binary.LittleEndian.Uint32(raw[30:34])
|
|
192
|
+
topDown := height < 0
|
|
193
|
+
if topDown {
|
|
194
|
+
height = -height
|
|
195
|
+
}
|
|
196
|
+
if width <= 0 || height <= 0 || width > 1<<15 || height > 1<<15 || (compression != 0 && compression != 3) || (bpp != 24 && bpp != 32) {
|
|
197
|
+
return nil, fmt.Errorf("unsupported BMP (bpp=%d compression=%d)", bpp, compression)
|
|
198
|
+
}
|
|
199
|
+
stride := (width*bpp/8 + 3) &^ 3
|
|
200
|
+
if offset < 0 || offset+stride*height > len(raw) {
|
|
201
|
+
return nil, errors.New("truncated BMP")
|
|
202
|
+
}
|
|
203
|
+
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
|
204
|
+
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
|
205
|
+
for y := 0; y < height; y++ {
|
|
206
|
+
row := y
|
|
207
|
+
if !topDown {
|
|
208
|
+
row = height - 1 - y
|
|
209
|
+
}
|
|
210
|
+
line := raw[offset+row*stride:]
|
|
211
|
+
for x := 0; x < width; x++ {
|
|
212
|
+
p := line[x*bpp/8:]
|
|
213
|
+
a := uint8(255)
|
|
214
|
+
if bpp == 32 {
|
|
215
|
+
a = p[3]
|
|
216
|
+
if a == 0 && compression == 0 {
|
|
217
|
+
a = 255 // BI_RGB 32-bit files rarely carry a real alpha channel
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
img.SetNRGBA(x, y, color.NRGBA{R: p[2], G: p[1], B: p[0], A: a})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return img, nil
|
|
224
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/base64"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Twins of upstream test/image-input.test.ts (support/images.ts fixtures).
|
|
14
|
+
|
|
15
|
+
const twoPixelBMP = "Qk1GAAAAAAAAADYAAAAoAAAAAgAAAAIAAAABABgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AAAAAP8AAP8AAA=="
|
|
16
|
+
|
|
17
|
+
const onePixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
|
18
|
+
|
|
19
|
+
func TestImageInput(t *testing.T) {
|
|
20
|
+
twin.Run(t, "image-input", "converts an unsupported image type even when auto-resize is disabled", func(t *testing.T) {
|
|
21
|
+
prepared, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: twoPixelBMP, MimeType: "image/bmp"}}, false)
|
|
22
|
+
if err != nil || len(prepared) != 1 {
|
|
23
|
+
t.Fatalf("%v %v", prepared, err)
|
|
24
|
+
}
|
|
25
|
+
if prepared[0].MimeType != "image/png" {
|
|
26
|
+
t.Fatalf("mime %s", prepared[0].MimeType)
|
|
27
|
+
}
|
|
28
|
+
raw, _ := base64.StdEncoding.DecodeString(prepared[0].Data)
|
|
29
|
+
if !bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")) {
|
|
30
|
+
t.Fatalf("not a PNG: %x", raw[:8])
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
twin.Run(t, "image-input", "fails when image bytes cannot be prepared", func(t *testing.T) {
|
|
35
|
+
_, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: base64.StdEncoding.EncodeToString([]byte("not an image")), MimeType: "image/png"}}, true)
|
|
36
|
+
if err == nil || !strings.Contains(err.Error(), "could not be prepared") {
|
|
37
|
+
t.Fatalf("%v", err)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func TestImageInputAdditions(t *testing.T) {
|
|
43
|
+
// Additions: the resize and validation paths the standard-library codecs stand in for.
|
|
44
|
+
if got, err := PrepareImagesForPi(nil, true); got != nil || err != nil {
|
|
45
|
+
t.Fatalf("no images: %v %v", got, err)
|
|
46
|
+
}
|
|
47
|
+
same, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "IMAGE/PNG; charset=x"}}, true)
|
|
48
|
+
if err != nil || same[0].Data != onePixelPNG || same[0].MimeType != "image/png" {
|
|
49
|
+
t.Fatalf("a small PNG must pass through: %+v %v", same, err)
|
|
50
|
+
}
|
|
51
|
+
if _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "text/plain"}}, true); err == nil {
|
|
52
|
+
t.Fatal("a non-image MIME type must be rejected")
|
|
53
|
+
}
|
|
54
|
+
if _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: "!!!", MimeType: "image/tiff"}}, false); err == nil {
|
|
55
|
+
t.Fatal("an undecodable conversion must fail")
|
|
56
|
+
}
|
|
57
|
+
}
|