@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,269 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
|
|
6
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Rebuilds chat history from a pi session file (port of src/pi/history.ts).
|
|
13
|
+
//
|
|
14
|
+
// A session from the catalogue exists only on disk, so subscribing to it has to reconstruct its
|
|
15
|
+
// state, or the client opens an empty conversation. The window matches what pi itself renders:
|
|
16
|
+
// the most recent compaction onward (ContextEntries), which is the transcript pi's own TUI shows
|
|
17
|
+
// on resume; reconstructing more would show the user history their agent has already forgotten.
|
|
18
|
+
//
|
|
19
|
+
// This is a second mapper, distinct from the streaming one: it works from finished messages
|
|
20
|
+
// rather than deltas, so there is no partial state and every tool call already has its result.
|
|
21
|
+
|
|
22
|
+
// ClearAllAnchor is the key under which the whole-conversation anchor is stored.
|
|
23
|
+
const ClearAllAnchor = ""
|
|
24
|
+
|
|
25
|
+
// RebuiltHistory is the result of a rebuild.
|
|
26
|
+
type RebuiltHistory struct {
|
|
27
|
+
Turns []ahptypes.Turn
|
|
28
|
+
// Anchors maps a turn id to the session entry that turn ends on: what truncation needs.
|
|
29
|
+
// chat/truncated keeps turns up to and including the named one, and pointing the agent at a
|
|
30
|
+
// turn's last entry expresses exactly that. The first user entry is recorded under
|
|
31
|
+
// ClearAllAnchor: navigating to it lands the leaf on its parent (null), which is how "clear
|
|
32
|
+
// everything" is expressed.
|
|
33
|
+
Anchors map[string]string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// RebuildOptions configure a rebuild.
|
|
37
|
+
type RebuildOptions struct {
|
|
38
|
+
// TurnIDPrefix prefixes generated turn ids; it only needs to be stable within a chat.
|
|
39
|
+
TurnIDPrefix string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func blocksOf(message map[string]any) []map[string]any {
|
|
43
|
+
if s, ok := message["content"].(string); ok {
|
|
44
|
+
return []map[string]any{{"type": "text", "text": s}}
|
|
45
|
+
}
|
|
46
|
+
var out []map[string]any
|
|
47
|
+
if arr, ok := message["content"].([]any); ok {
|
|
48
|
+
for _, b := range arr {
|
|
49
|
+
if m, ok := b.(map[string]any); ok {
|
|
50
|
+
out = append(out, m)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func plainText(message map[string]any) string {
|
|
58
|
+
var sb strings.Builder
|
|
59
|
+
for _, b := range blocksOf(message) {
|
|
60
|
+
if b["type"] == "text" {
|
|
61
|
+
if s, ok := b["text"].(string); ok {
|
|
62
|
+
sb.WriteString(s)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return sb.String()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func numberPtr(v any) *int64 {
|
|
70
|
+
switch n := v.(type) {
|
|
71
|
+
case float64:
|
|
72
|
+
i := int64(n)
|
|
73
|
+
return &i
|
|
74
|
+
}
|
|
75
|
+
return nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func storedUsage(message map[string]any) *ahptypes.UsageInfo {
|
|
79
|
+
usage, ok := message["usage"].(map[string]any)
|
|
80
|
+
if !ok {
|
|
81
|
+
return nil
|
|
82
|
+
}
|
|
83
|
+
info := &ahptypes.UsageInfo{
|
|
84
|
+
InputTokens: numberPtr(usage["input"]), OutputTokens: numberPtr(usage["output"]), CacheReadTokens: numberPtr(usage["cacheRead"]),
|
|
85
|
+
}
|
|
86
|
+
if model, _ := message["model"].(string); model != "" {
|
|
87
|
+
info.Model = &model
|
|
88
|
+
}
|
|
89
|
+
return info
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func textResult(text string) []ahptypes.ToolResultContent {
|
|
93
|
+
return []ahptypes.ToolResultContent{{Value: &ahptypes.ToolResultTextContent{Type: ahptypes.ToolResultContentTypeText, Text: text}}}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type pendingTurn struct {
|
|
97
|
+
id, startedAt string
|
|
98
|
+
message ahptypes.Message
|
|
99
|
+
parts []ahptypes.ResponsePart
|
|
100
|
+
usage *ahptypes.UsageInfo
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func stringOr(v any, def string) string {
|
|
104
|
+
if s, ok := v.(string); ok && s != "" {
|
|
105
|
+
return s
|
|
106
|
+
}
|
|
107
|
+
return def
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// RebuildHistory converts session entries into completed turns.
|
|
111
|
+
//
|
|
112
|
+
// Turn boundaries come from user messages: each one opens a turn that absorbs every assistant
|
|
113
|
+
// message and tool result until the next. That mirrors the live mapper, where a turn spans
|
|
114
|
+
// everything from one prompt to agent_settled.
|
|
115
|
+
func RebuildHistory(entries []pisession.Entry, opts RebuildOptions) RebuiltHistory {
|
|
116
|
+
prefix := opts.TurnIDPrefix
|
|
117
|
+
if prefix == "" {
|
|
118
|
+
prefix = "turn"
|
|
119
|
+
}
|
|
120
|
+
turns := []ahptypes.Turn{}
|
|
121
|
+
anchors := map[string]string{}
|
|
122
|
+
var lastEntryID string
|
|
123
|
+
var current *pendingTurn
|
|
124
|
+
toolCallParts := map[string]int{}
|
|
125
|
+
|
|
126
|
+
closeTurn := func() {
|
|
127
|
+
if current == nil {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if lastEntryID != "" {
|
|
131
|
+
anchors[current.id] = lastEntryID
|
|
132
|
+
}
|
|
133
|
+
started := current.startedAt
|
|
134
|
+
turns = append(turns, ahptypes.Turn{
|
|
135
|
+
Id: current.id, StartedAt: &started, Message: current.message,
|
|
136
|
+
ResponseParts: current.parts, Usage: current.usage, State: ahptypes.TurnStateComplete,
|
|
137
|
+
})
|
|
138
|
+
current = nil
|
|
139
|
+
toolCallParts = map[string]int{}
|
|
140
|
+
}
|
|
141
|
+
openTurn := func(id, startedAt string, message ahptypes.Message) {
|
|
142
|
+
closeTurn()
|
|
143
|
+
current = &pendingTurn{id: id, startedAt: startedAt, message: message, parts: []ahptypes.ResponsePart{}}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for _, entry := range entries {
|
|
147
|
+
if entry.Role() == "user" {
|
|
148
|
+
if _, set := anchors[ClearAllAnchor]; !set {
|
|
149
|
+
anchors[ClearAllAnchor] = entry.ID()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if entry.Type() == "compaction" {
|
|
154
|
+
// The summary replaces everything before it. Surfacing it as a turn of its own tells
|
|
155
|
+
// the user why the history starts where it does.
|
|
156
|
+
closeTurn()
|
|
157
|
+
started := entry.Timestamp()
|
|
158
|
+
summary, _ := entry["summary"].(string)
|
|
159
|
+
turns = append(turns, ahptypes.Turn{
|
|
160
|
+
Id: prefix + "-compaction-" + entry.ID(), StartedAt: &started,
|
|
161
|
+
Message: ahptypes.Message{Text: "", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
|
|
162
|
+
ResponseParts: []ahptypes.ResponsePart{{Value: &ahptypes.SystemNotificationResponsePart{
|
|
163
|
+
Kind: ahptypes.ResponsePartKindSystemNotification,
|
|
164
|
+
Content: ahptypes.NewStringOrMarkdownPlain("Conversation compacted.\n\n" + summary),
|
|
165
|
+
}}},
|
|
166
|
+
State: ahptypes.TurnStateComplete,
|
|
167
|
+
})
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if entry.Type() != "message" {
|
|
171
|
+
continue // model / thinking-level changes and labels carry no transcript
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
message := entry.Message()
|
|
175
|
+
switch message["role"] {
|
|
176
|
+
case "user":
|
|
177
|
+
openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent(message["content"]))
|
|
178
|
+
case "assistant":
|
|
179
|
+
if current == nil {
|
|
180
|
+
// An assistant message with no preceding user message (a truncated file, or history
|
|
181
|
+
// that starts mid-turn): give it a turn so the content is not dropped.
|
|
182
|
+
openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent(""))
|
|
183
|
+
}
|
|
184
|
+
for index, block := range blocksOf(message) {
|
|
185
|
+
partID := current.id + ":" + entry.ID() + ":" + itoa(index)
|
|
186
|
+
switch {
|
|
187
|
+
case block["type"] == "text" && stringOr(block["text"], "") != "":
|
|
188
|
+
current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.MarkdownResponsePart{
|
|
189
|
+
Kind: ahptypes.ResponsePartKindMarkdown, Id: partID, Content: block["text"].(string)}})
|
|
190
|
+
case block["type"] == "thinking" && stringOr(block["thinking"], "") != "":
|
|
191
|
+
current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.ReasoningResponsePart{
|
|
192
|
+
Kind: ahptypes.ResponsePartKindReasoning, Id: partID, Content: block["thinking"].(string)}})
|
|
193
|
+
case block["type"] == "toolCall" && stringOr(block["id"], "") != "":
|
|
194
|
+
id := block["id"].(string)
|
|
195
|
+
name := stringOr(block["name"], "tool")
|
|
196
|
+
toolCallParts[id] = len(current.parts)
|
|
197
|
+
running := &ahptypes.ToolCallRunningState{
|
|
198
|
+
Status: ahptypes.ToolCallStatusRunning, ToolCallId: id, ToolName: name, DisplayName: name,
|
|
199
|
+
InvocationMessage: ahptypes.NewStringOrMarkdownPlain(name), Confirmed: ahptypes.ToolCallConfirmationReasonSetting,
|
|
200
|
+
}
|
|
201
|
+
if args, present := block["arguments"]; present {
|
|
202
|
+
if s, err := mapper.StringifyJSON(args, false); err == nil {
|
|
203
|
+
running.ToolInput = &ahptypes.ToolInput{Inline: &s}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.ToolCallResponsePart{
|
|
207
|
+
Kind: ahptypes.ResponsePartKindToolCall, ToolCall: ahptypes.ToolCallState{Value: running}}})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if u := storedUsage(message); u != nil {
|
|
211
|
+
current.usage = u
|
|
212
|
+
}
|
|
213
|
+
case "toolResult":
|
|
214
|
+
callID, _ := message["toolCallId"].(string)
|
|
215
|
+
idx, found := toolCallParts[callID]
|
|
216
|
+
if current == nil || callID == "" || !found {
|
|
217
|
+
break
|
|
218
|
+
}
|
|
219
|
+
part, ok := current.parts[idx].Value.(*ahptypes.ToolCallResponsePart)
|
|
220
|
+
if !ok {
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
running, ok := part.ToolCall.Value.(*ahptypes.ToolCallRunningState)
|
|
224
|
+
if !ok {
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
name := stringOr(message["toolName"], "tool")
|
|
228
|
+
success := message["isError"] != true
|
|
229
|
+
past := "Ran " + name
|
|
230
|
+
if !success {
|
|
231
|
+
past = name + " failed"
|
|
232
|
+
}
|
|
233
|
+
done := &ahptypes.ToolCallCompletedState{
|
|
234
|
+
Status: ahptypes.ToolCallStatusCompleted, ToolCallId: running.ToolCallId, ToolName: running.ToolName,
|
|
235
|
+
DisplayName: running.DisplayName, InvocationMessage: running.InvocationMessage, ToolInput: running.ToolInput,
|
|
236
|
+
Confirmed: running.Confirmed, Success: success, PastTenseMessage: ahptypes.NewStringOrMarkdownPlain(past),
|
|
237
|
+
}
|
|
238
|
+
if text := plainText(message); text != "" {
|
|
239
|
+
done.Content = textResult(text)
|
|
240
|
+
}
|
|
241
|
+
current.parts[idx] = ahptypes.ResponsePart{Value: &ahptypes.ToolCallResponsePart{
|
|
242
|
+
Kind: ahptypes.ResponsePartKindToolCall, ToolCall: ahptypes.ToolCallState{Value: done}}}
|
|
243
|
+
case "bashExecution":
|
|
244
|
+
// pi records `!command` runs as their own message role; they are part of the transcript
|
|
245
|
+
// even though no model produced them.
|
|
246
|
+
command, _ := message["command"].(string)
|
|
247
|
+
output, _ := message["output"].(string)
|
|
248
|
+
if current == nil {
|
|
249
|
+
openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent("!"+command))
|
|
250
|
+
}
|
|
251
|
+
current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.SystemNotificationResponsePart{
|
|
252
|
+
Kind: ahptypes.ResponsePartKindSystemNotification, Content: ahptypes.NewStringOrMarkdownPlain("`" + command + "`\n\n" + output)}})
|
|
253
|
+
}
|
|
254
|
+
// Recorded after the entry has been folded in, so a user message that opens a new turn
|
|
255
|
+
// does not become the previous turn's anchor.
|
|
256
|
+
lastEntryID = entry.ID()
|
|
257
|
+
}
|
|
258
|
+
closeTurn()
|
|
259
|
+
return RebuiltHistory{Turns: turns, Anchors: anchors}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// RebuildHistoryFromSession rebuilds the turns of a session with the window pi's TUI shows.
|
|
263
|
+
func RebuildHistoryFromSession(store SessionStore, opts RebuildOptions) RebuiltHistory {
|
|
264
|
+
return RebuildHistory(store.ContextEntries(), opts)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
func itoa(i int) string {
|
|
268
|
+
return mapper.Itoa(i)
|
|
269
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
"testing"
|
|
12
|
+
|
|
13
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
14
|
+
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
17
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
18
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// writeSession writes a Pi session file containing one full turn with a tool call, then a second
|
|
23
|
+
// user message (upstream test/support/hydrated-session.ts).
|
|
24
|
+
func writeSession(t testing.TB, root, id, cwd string, includeImage bool) string {
|
|
25
|
+
t.Helper()
|
|
26
|
+
dir := fixtureSessionDirectory(t, root, cwd)
|
|
27
|
+
at := "2026-01-01T00:00:00.000Z"
|
|
28
|
+
line := func(v map[string]any) string { raw, _ := json.Marshal(v); return string(raw) }
|
|
29
|
+
lines := []string{line(map[string]any{"type": "session", "id": id, "parentId": nil, "timestamp": at, "version": 3, "cwd": cwd})}
|
|
30
|
+
var parent any
|
|
31
|
+
n := 0
|
|
32
|
+
push := func(entry map[string]any) {
|
|
33
|
+
n++
|
|
34
|
+
entryID := fmt.Sprintf("e%d", n)
|
|
35
|
+
entry["id"], entry["parentId"], entry["timestamp"] = entryID, parent, at
|
|
36
|
+
lines = append(lines, line(entry))
|
|
37
|
+
parent = entryID
|
|
38
|
+
}
|
|
39
|
+
var userContent any = "Read note.txt"
|
|
40
|
+
if includeImage {
|
|
41
|
+
userContent = []any{obj{"type": "text", "text": "Read note.txt"}, obj{"type": "image", "data": onePixelPNG, "mimeType": "image/png"}}
|
|
42
|
+
}
|
|
43
|
+
push(obj{"type": "message", "message": obj{"role": "user", "content": userContent, "timestamp": 0}})
|
|
44
|
+
push(obj{"type": "message", "message": obj{
|
|
45
|
+
"role": "assistant",
|
|
46
|
+
"content": []any{obj{"type": "thinking", "thinking": "I should read it."},
|
|
47
|
+
obj{"type": "toolCall", "id": "tc-1", "name": "read", "arguments": obj{"path": "note.txt"}}},
|
|
48
|
+
"usage": obj{"input": 10, "output": 5, "cacheRead": 0}, "provider": "fixture", "model": "test-model", "timestamp": 0,
|
|
49
|
+
}})
|
|
50
|
+
push(obj{"type": "message", "message": obj{
|
|
51
|
+
"role": "toolResult", "toolCallId": "tc-1", "toolName": "read", "content": []any{obj{"type": "text", "text": "ALPHA"}}, "timestamp": 0,
|
|
52
|
+
}})
|
|
53
|
+
push(obj{"type": "message", "message": obj{
|
|
54
|
+
"role": "assistant", "content": []any{obj{"type": "text", "text": "It says ALPHA."}}, "provider": "fixture", "model": "test-model", "timestamp": 0,
|
|
55
|
+
}})
|
|
56
|
+
push(obj{"type": "message", "message": obj{"role": "user", "content": "Thanks", "timestamp": 0}})
|
|
57
|
+
path := filepath.Join(dir, "2026-01-01T00-00-00-000Z_"+id+".jsonl")
|
|
58
|
+
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
return path
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// recordingBackend records what a resumed session actually asks the agent to do.
|
|
65
|
+
type recordingBackend struct {
|
|
66
|
+
mu sync.Mutex
|
|
67
|
+
prompts []string
|
|
68
|
+
listeners []func(mapper.Event)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func (b *recordingBackend) Subscribe(l func(mapper.Event)) func() {
|
|
72
|
+
b.mu.Lock()
|
|
73
|
+
b.listeners = append(b.listeners, l)
|
|
74
|
+
b.mu.Unlock()
|
|
75
|
+
return func() {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func (b *recordingBackend) Prompt(_ context.Context, text string, _ []mapper.Image) error {
|
|
79
|
+
b.mu.Lock()
|
|
80
|
+
b.prompts = append(b.prompts, text)
|
|
81
|
+
ls := append([]func(mapper.Event){}, b.listeners...)
|
|
82
|
+
b.mu.Unlock()
|
|
83
|
+
for _, typ := range []string{"agent_start", "agent_settled"} {
|
|
84
|
+
for _, l := range ls {
|
|
85
|
+
l(mapper.Event{"type": typ})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return nil
|
|
89
|
+
}
|
|
90
|
+
func (b *recordingBackend) Steer(context.Context, string, []mapper.Image) error { return nil }
|
|
91
|
+
func (b *recordingBackend) Abort(context.Context) error { return nil }
|
|
92
|
+
|
|
93
|
+
func (b *recordingBackend) promptList() []string {
|
|
94
|
+
b.mu.Lock()
|
|
95
|
+
defer b.mu.Unlock()
|
|
96
|
+
return append([]string(nil), b.prompts...)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type hydratedOptions struct {
|
|
100
|
+
deleteFile func(path string) (pi.SessionFileDeletionResult, error)
|
|
101
|
+
includeImage bool
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type hydratedFixture struct {
|
|
105
|
+
t *testing.T
|
|
106
|
+
host *host.Host
|
|
107
|
+
services *pi.Services
|
|
108
|
+
client *testkit.Client
|
|
109
|
+
sessionID string
|
|
110
|
+
root string
|
|
111
|
+
workspace string
|
|
112
|
+
backend *recordingBackend
|
|
113
|
+
mu sync.Mutex
|
|
114
|
+
deleted []string
|
|
115
|
+
extra int
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (f *hydratedFixture) deletedFiles() []string {
|
|
119
|
+
f.mu.Lock()
|
|
120
|
+
defer f.mu.Unlock()
|
|
121
|
+
return append([]string(nil), f.deleted...)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func (f *hydratedFixture) session() string { return wire.SessionURI(f.sessionID) }
|
|
125
|
+
func (f *hydratedFixture) chat() string { return wire.ChatURI(f.sessionID) }
|
|
126
|
+
|
|
127
|
+
// connectAsVSCode attaches another client that identifies itself as an editor window.
|
|
128
|
+
func (f *hydratedFixture) connectAsVSCode() *testkit.Client {
|
|
129
|
+
f.t.Helper()
|
|
130
|
+
f.extra++
|
|
131
|
+
c := testkit.Connect(f.t, f.host)
|
|
132
|
+
f.t.Cleanup(c.Close)
|
|
133
|
+
c.Initialize(fmt.Sprintf("vscode-client-%d", f.extra+1), obj{"clientInfo": obj{"name": "vscode-editor-window", "title": "VS Code"}})
|
|
134
|
+
return c
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func startHydrated(t *testing.T, o hydratedOptions) *hydratedFixture {
|
|
138
|
+
t.Helper()
|
|
139
|
+
f := &hydratedFixture{t: t, root: t.TempDir(), backend: &recordingBackend{}}
|
|
140
|
+
f.workspace = filepath.Join(t.TempDir(), "pi ahp hydrate cwd")
|
|
141
|
+
if err := os.MkdirAll(f.workspace, 0o755); err != nil {
|
|
142
|
+
t.Fatal(err)
|
|
143
|
+
}
|
|
144
|
+
f.sessionID = newID()
|
|
145
|
+
writeSession(t, f.root, f.sessionID, f.workspace, o.includeImage)
|
|
146
|
+
f.host = testkit.NewHost(host.Options{})
|
|
147
|
+
f.host.Store() // root installed by NewHost
|
|
148
|
+
f.services = pi.NewServices(pi.ServicesOptions{
|
|
149
|
+
Host: f.host, SessionRoot: f.root, DefaultWorkingDirectory: f.workspace,
|
|
150
|
+
CreateBackend: func(*pi.LiveSession) (pi.Backend, error) { return f.backend, nil },
|
|
151
|
+
CreateSessionManager: pi.PersistentStorage(filepath.Join(f.root, "created")),
|
|
152
|
+
DefaultSelection: func() *ahptypes.ModelSelection {
|
|
153
|
+
return &ahptypes.ModelSelection{Id: "fallback-model", Config: map[string]json.RawMessage{"thinkingLevel": json.RawMessage(`"medium"`)}}
|
|
154
|
+
},
|
|
155
|
+
DeleteFile: func(path string) (pi.SessionFileDeletionResult, error) {
|
|
156
|
+
f.mu.Lock()
|
|
157
|
+
f.deleted = append(f.deleted, path)
|
|
158
|
+
f.mu.Unlock()
|
|
159
|
+
if o.deleteFile != nil {
|
|
160
|
+
return o.deleteFile(path)
|
|
161
|
+
}
|
|
162
|
+
_ = os.Remove(path)
|
|
163
|
+
return pi.SessionFileDeletionResult{OK: true}, nil
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
f.host.Serve(f.services.Capabilities())
|
|
167
|
+
f.client = testkit.Connect(t, f.host)
|
|
168
|
+
t.Cleanup(f.client.Close)
|
|
169
|
+
f.client.Initialize("hydrate-client", nil)
|
|
170
|
+
return f
|
|
171
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"reflect"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
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/pisession"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// Twins of upstream test/hydrated-session-lifecycle.test.ts: operations performed after a durable
|
|
20
|
+
// Pi session has been hydrated.
|
|
21
|
+
|
|
22
|
+
func (f *hydratedFixture) listItems() []obj {
|
|
23
|
+
f.t.Helper()
|
|
24
|
+
var r struct{ Items []obj }
|
|
25
|
+
f.client.Decode(f.client.Must("listSessions", obj{"channel": wire.RootChannel}), &r)
|
|
26
|
+
return r.Items
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (f *hydratedFixture) turnStarted(turnID, text string) {
|
|
30
|
+
f.client.Dispatch(f.chat(), obj{"type": "chat/turnStarted", "turnId": turnID, "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), "message": userMessage(text)})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestReadStateAfterHydration(t *testing.T) {
|
|
34
|
+
f := startHydrated(t, hydratedOptions{})
|
|
35
|
+
isRead := float64(ahptypes.SessionStatusIsRead)
|
|
36
|
+
|
|
37
|
+
twin.Run(t, "hydrated-session-lifecycle", "reports every catalogue entry as read", func(t *testing.T) {
|
|
38
|
+
items := f.listItems()
|
|
39
|
+
if int64(items[0]["status"].(float64))&int64(isRead) == 0 {
|
|
40
|
+
t.Fatalf("status = %v", items[0]["status"])
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
twin.Run(t, "hydrated-session-lifecycle", "reports a hydrated session as read", func(t *testing.T) {
|
|
45
|
+
state := snapshotState[ahptypes.SessionState](t, f.client, f.session())
|
|
46
|
+
if state.Status&ahptypes.SessionStatusIsRead == 0 {
|
|
47
|
+
t.Fatalf("status = %d", state.Status)
|
|
48
|
+
}
|
|
49
|
+
if state.Title != "Read note.txt" {
|
|
50
|
+
t.Fatalf("title = %q", state.Title)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
twin.Run(t, "hydrated-session-lifecycle", "keeps session-owned read state when a starting turn marks the chat unread", func(t *testing.T) {
|
|
55
|
+
f.client.Subscribe(f.chat())
|
|
56
|
+
if before := f.host.Store().Chat(f.chat()).Status; before&ahptypes.SessionStatusIsRead == 0 {
|
|
57
|
+
t.Fatalf("chat status before = %d", before)
|
|
58
|
+
}
|
|
59
|
+
f.turnStarted("t-unread", "hi")
|
|
60
|
+
f.client.Ping()
|
|
61
|
+
if got := f.host.Store().Chat(f.chat()).Status & ahptypes.SessionStatusIsRead; got != 0 {
|
|
62
|
+
t.Fatalf("chat still read: %d", got)
|
|
63
|
+
}
|
|
64
|
+
if f.host.Store().Session(f.session()).Status&ahptypes.SessionStatusIsRead == 0 {
|
|
65
|
+
t.Fatal("the session lost its read bit")
|
|
66
|
+
}
|
|
67
|
+
for _, item := range f.listItems() {
|
|
68
|
+
if item["resource"] == f.session() && int64(item["status"].(float64))&int64(isRead) == 0 {
|
|
69
|
+
t.Fatalf("listed status = %v", item["status"])
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestDisposingAHydratedSession(t *testing.T) {
|
|
76
|
+
twin.Run(t, "hydrated-session-lifecycle", "removes it from the catalogue and deletes the file", func(t *testing.T) {
|
|
77
|
+
f := startHydrated(t, hydratedOptions{})
|
|
78
|
+
f.client.Subscribe(f.session())
|
|
79
|
+
f.client.Must("disposeSession", obj{"channel": f.session()})
|
|
80
|
+
if f.host.Store().Has(f.session()) {
|
|
81
|
+
t.Fatal("the session channel remains")
|
|
82
|
+
}
|
|
83
|
+
if len(f.deletedFiles()) != 1 {
|
|
84
|
+
t.Fatalf("deleted = %v", f.deletedFiles())
|
|
85
|
+
}
|
|
86
|
+
if got := f.listItems(); len(got) != 0 {
|
|
87
|
+
t.Fatalf("a disposed session must not come back on the next listing: %v", got)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
twin.Run(t, "hydrated-session-lifecycle", "prevents an unloaded session from being recreated or hydrated during deletion", func(t *testing.T) {
|
|
92
|
+
started := make(chan struct{})
|
|
93
|
+
finish := make(chan bool)
|
|
94
|
+
var once sync.Once
|
|
95
|
+
f := startHydrated(t, hydratedOptions{deleteFile: func(path string) (pi.SessionFileDeletionResult, error) {
|
|
96
|
+
once.Do(func() { close(started) })
|
|
97
|
+
ok := <-finish
|
|
98
|
+
if ok {
|
|
99
|
+
_ = os.Remove(path)
|
|
100
|
+
}
|
|
101
|
+
return pi.SessionFileDeletionResult{OK: ok}, nil
|
|
102
|
+
}})
|
|
103
|
+
defer func() {
|
|
104
|
+
select {
|
|
105
|
+
case finish <- false:
|
|
106
|
+
default:
|
|
107
|
+
}
|
|
108
|
+
}()
|
|
109
|
+
uri := f.session()
|
|
110
|
+
disposed := make(chan *testkit.RPCError, 1)
|
|
111
|
+
go func() { _, err := f.client.Request("disposeSession", obj{"channel": uri}); disposed <- err }()
|
|
112
|
+
select {
|
|
113
|
+
case <-started:
|
|
114
|
+
case <-time.After(testkit.Timeout):
|
|
115
|
+
t.Fatal("timed out waiting for durable deletion to start")
|
|
116
|
+
}
|
|
117
|
+
f.client.ExpectError("subscribe", obj{"channel": uri}, wire.CodeNotFound)
|
|
118
|
+
f.client.ExpectError("createSession", obj{"channel": uri}, wire.CodeSessionAlreadyExists)
|
|
119
|
+
|
|
120
|
+
finish <- true
|
|
121
|
+
if err := <-disposed; err != nil {
|
|
122
|
+
t.Fatal(err)
|
|
123
|
+
}
|
|
124
|
+
deleted := f.deletedFiles()
|
|
125
|
+
if len(deleted) != 1 {
|
|
126
|
+
t.Fatalf("deleted = %v", deleted)
|
|
127
|
+
}
|
|
128
|
+
if _, err := os.Stat(deleted[0]); !os.IsNotExist(err) {
|
|
129
|
+
t.Fatalf("the deleted session file remains: %v", err)
|
|
130
|
+
}
|
|
131
|
+
for _, item := range f.listItems() {
|
|
132
|
+
if item["resource"] == uri {
|
|
133
|
+
t.Fatal("the disposed session is still listed")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func TestResumingAHydratedSession(t *testing.T) {
|
|
140
|
+
twin.Run(t, "hydrated-session-lifecycle", "starts an agent on the first turn and prompts it", func(t *testing.T) {
|
|
141
|
+
f := startHydrated(t, hydratedOptions{})
|
|
142
|
+
f.client.Subscribe(f.chat())
|
|
143
|
+
if got := f.backend.promptList(); len(got) != 0 {
|
|
144
|
+
t.Fatalf("browsing history must not start an agent: %v", got)
|
|
145
|
+
}
|
|
146
|
+
f.turnStarted("t-resume", "continue please")
|
|
147
|
+
testkit.Eventually(t, "the resumed prompt to reach the backend", func() bool { return len(f.backend.promptList()) == 1 })
|
|
148
|
+
if got := f.backend.promptList(); !reflect.DeepEqual(got, []string{"continue please"}) {
|
|
149
|
+
t.Fatalf("prompts = %v", got)
|
|
150
|
+
}
|
|
151
|
+
testkit.Eventually(t, "the resumed turn to settle", func() bool { return f.host.Store().Chat(f.chat()).ActiveTurn == nil })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
twin.Run(t, "hydrated-session-lifecycle", "resumes onto the existing transcript rather than a fresh one", func(t *testing.T) {
|
|
155
|
+
f := startHydrated(t, hydratedOptions{})
|
|
156
|
+
f.client.Subscribe(f.chat())
|
|
157
|
+
before := len(f.host.Store().Chat(f.chat()).Turns)
|
|
158
|
+
f.turnStarted("t-append", "and again")
|
|
159
|
+
testkit.Eventually(t, "the resumed prompt to reach the backend", func() bool { return len(f.backend.promptList()) == 1 })
|
|
160
|
+
testkit.Eventually(t, "the resumed turn to append after history", func() bool { return len(f.host.Store().Chat(f.chat()).Turns) == before+1 })
|
|
161
|
+
turns := f.host.Store().Chat(f.chat()).Turns
|
|
162
|
+
if turns[0].Message.Text != "Read note.txt" || turns[len(turns)-1].Message.Text != "and again" {
|
|
163
|
+
t.Fatalf("turns = %+v", turnShapes(turns))
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func TestModelSelectionOnAHydratedSession(t *testing.T) {
|
|
169
|
+
twin.Run(t, "hydrated-session-lifecycle", "seeds the picker from the model the session was using", func(t *testing.T) {
|
|
170
|
+
f := startHydrated(t, hydratedOptions{})
|
|
171
|
+
chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
|
|
172
|
+
if chat.Draft == nil || chat.Draft.Model == nil || chat.Draft.Model.Id != "fixture/test-model" {
|
|
173
|
+
t.Fatalf("draft = %+v", chat.Draft)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestRenamingAHydratedSession(t *testing.T) {
|
|
179
|
+
twin.Run(t, "hydrated-session-lifecycle", "persists the name into the session file", func(t *testing.T) {
|
|
180
|
+
f := startHydrated(t, hydratedOptions{})
|
|
181
|
+
f.client.Subscribe(f.session())
|
|
182
|
+
f.client.Dispatch(f.session(), obj{"type": "session/titleChanged", "title": "Archived work"})
|
|
183
|
+
f.client.Ping()
|
|
184
|
+
if got := f.host.Store().Session(f.session()).Title; got != "Archived work" {
|
|
185
|
+
t.Fatalf("title = %q", got)
|
|
186
|
+
}
|
|
187
|
+
testkit.Eventually(t, "the name to reach the file", func() bool {
|
|
188
|
+
file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
|
|
189
|
+
if file == "" {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
m, err := pisession.Open(file)
|
|
193
|
+
return err == nil && m.SessionName() == "Archived work"
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
}
|