@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,128 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"reflect"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Twins of upstream test/models.test.ts: mapping Pi's model catalogue onto protocol agent/model
|
|
14
|
+
// descriptions.
|
|
15
|
+
|
|
16
|
+
func testModel(mutate ...func(*pi.Model)) pi.Model {
|
|
17
|
+
m := pi.Model{
|
|
18
|
+
ID: "claude-sonnet-4", Name: "Claude Sonnet 4", API: "anthropic-messages", Provider: "anthropic",
|
|
19
|
+
BaseURL: "https://api.anthropic.com", Reasoning: true, Input: []string{"text", "image"},
|
|
20
|
+
Cost: json.RawMessage(`{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75}`),
|
|
21
|
+
ContextWindow: 200_000, MaxTokens: 16_384,
|
|
22
|
+
}
|
|
23
|
+
for _, f := range mutate {
|
|
24
|
+
f(&m)
|
|
25
|
+
}
|
|
26
|
+
return m
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func TestModelMapping(t *testing.T) {
|
|
30
|
+
twin.Run(t, "models", "offers the standard thinking levels when no extended levels are declared", func(t *testing.T) {
|
|
31
|
+
if got := pi.SupportedThinkingLevels(testModel()); !reflect.DeepEqual(got, []string{"off", "minimal", "low", "medium", "high"}) {
|
|
32
|
+
t.Fatalf("levels = %v", got)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
twin.Run(t, "models", "offers only `off` for a model without reasoning", func(t *testing.T) {
|
|
37
|
+
got := pi.SupportedThinkingLevels(testModel(func(m *pi.Model) { m.Reasoning = false }))
|
|
38
|
+
if !reflect.DeepEqual(got, []string{"off"}) {
|
|
39
|
+
t.Fatalf("levels = %v", got)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
twin.Run(t, "models", "drops unsupported levels and opts into extended levels exactly as pi does", func(t *testing.T) {
|
|
44
|
+
m := testModel(func(m *pi.Model) {
|
|
45
|
+
m.ThinkingLevelMap = map[string]json.RawMessage{"off": json.RawMessage("null"), "minimal": json.RawMessage("null"), "xhigh": json.RawMessage(`"xhigh"`), "max": json.RawMessage("null")}
|
|
46
|
+
})
|
|
47
|
+
if got := pi.SupportedThinkingLevels(m); !reflect.DeepEqual(got, []string{"low", "medium", "high", "xhigh"}) {
|
|
48
|
+
t.Fatalf("levels = %v", got)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
twin.Run(t, "models", "uses pi's provider-qualified model ids on the wire", func(t *testing.T) {
|
|
53
|
+
if got := pi.ModelSelectionID("anthropic", "claude-sonnet-4"); got != "anthropic/claude-sonnet-4" {
|
|
54
|
+
t.Fatalf("id = %s", got)
|
|
55
|
+
}
|
|
56
|
+
m := testModel(func(m *pi.Model) { m.Provider, m.ID = "openrouter", "anthropic/claude-sonnet-4" })
|
|
57
|
+
if got := m.SelectionID(); got != "openrouter/anthropic/claude-sonnet-4" {
|
|
58
|
+
t.Fatalf("id = %s", got)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
twin.Run(t, "models", "resolves qualified ids without confusing providers", func(t *testing.T) {
|
|
63
|
+
direct := testModel(func(m *pi.Model) { m.Provider, m.ID, m.Name = "openai", "gpt-6-astra", "GPT-6 Astra (API)" })
|
|
64
|
+
codex := testModel(func(m *pi.Model) { m.Provider, m.ID, m.Name = "openai-codex", "gpt-6-astra", "GPT-6 Astra (Codex)" })
|
|
65
|
+
models := []pi.Model{direct, codex}
|
|
66
|
+
if got, ok := pi.FindModelBySelectionID(models, codex.SelectionID(), nil); !ok || got.Name != codex.Name {
|
|
67
|
+
t.Fatalf("qualified lookup = %+v %v", got, ok)
|
|
68
|
+
}
|
|
69
|
+
if _, ok := pi.FindModelBySelectionID(models, "gpt-6-astra", nil); ok {
|
|
70
|
+
t.Fatal("an ambiguous bare id must not resolve")
|
|
71
|
+
}
|
|
72
|
+
if got, ok := pi.FindModelBySelectionID(models, "gpt-6-astra", &codex); !ok || got.Name != codex.Name {
|
|
73
|
+
t.Fatalf("bare id with the current model = %+v %v", got, ok)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
twin.Run(t, "models", "accepts an unambiguous legacy bare model id", func(t *testing.T) {
|
|
78
|
+
only := testModel()
|
|
79
|
+
other := testModel(func(m *pi.Model) { m.ID = "other-model" })
|
|
80
|
+
if got, ok := pi.FindModelBySelectionID([]pi.Model{only, other}, only.ID, nil); !ok || got.ID != only.ID {
|
|
81
|
+
t.Fatalf("got %+v %v", got, ok)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
twin.Run(t, "models", "exposes thinking level as a model configSchema", func(t *testing.T) {
|
|
86
|
+
info := pi.ToSessionModelInfo(testModel())
|
|
87
|
+
if info.ConfigSchema == nil {
|
|
88
|
+
t.Fatal("no configSchema")
|
|
89
|
+
}
|
|
90
|
+
property, ok := info.ConfigSchema.Properties[pi.ThinkingConfigKey]
|
|
91
|
+
if !ok || property.Type != "string" || string(*property.Default) != `"medium"` {
|
|
92
|
+
t.Fatalf("property = %+v", property)
|
|
93
|
+
}
|
|
94
|
+
sameJSON(t, property.Enum, []string{"off", "minimal", "low", "medium", "high"}, "enum")
|
|
95
|
+
if info.Id != "anthropic/claude-sonnet-4" || info.Provider != pi.Provider || info.MaxContextWindow == nil || *info.MaxContextWindow != 200_000 {
|
|
96
|
+
t.Fatalf("info = %+v", info)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
twin.Run(t, "models", "maps pi input modalities onto the vision capability", func(t *testing.T) {
|
|
101
|
+
if v := pi.ToSessionModelInfo(testModel()).SupportsVision; v == nil || !*v {
|
|
102
|
+
t.Fatal("an image-capable model must support vision")
|
|
103
|
+
}
|
|
104
|
+
if v := pi.ToSessionModelInfo(testModel(func(m *pi.Model) { m.Input = []string{"text"} })).SupportsVision; v == nil || *v {
|
|
105
|
+
t.Fatal("a text-only model must not support vision")
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
twin.Run(t, "models", "omits the configSchema when there is nothing to choose", func(t *testing.T) {
|
|
110
|
+
// A single-option picker is worse than no picker.
|
|
111
|
+
if pi.ToSessionModelInfo(testModel(func(m *pi.Model) { m.Reasoning = false })).ConfigSchema != nil {
|
|
112
|
+
t.Fatal("configSchema must be omitted")
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
twin.Run(t, "models", "produces a schema-conforming AgentInfo", func(t *testing.T) {
|
|
117
|
+
agent := pi.BuildAgentInfo([]pi.Model{testModel(), testModel(func(m *pi.Model) { m.ID, m.Name, m.Reasoning = "gpt-5", "GPT-5", false })})
|
|
118
|
+
if agent.Provider != pi.Provider || len(agent.Models) != 2 {
|
|
119
|
+
t.Fatalf("agent = %+v", agent)
|
|
120
|
+
}
|
|
121
|
+
// No capabilities declared: one chat, one working directory. Their absence is what tells a
|
|
122
|
+
// client not to attempt those calls.
|
|
123
|
+
if agent.Capabilities != nil || len(agent.ProtectedResources) != 0 || agent.Customizations != nil {
|
|
124
|
+
t.Fatalf("agent declares more than it supports: %+v", agent)
|
|
125
|
+
}
|
|
126
|
+
testkit.AssertValid(t, "state", "AgentInfo", agent)
|
|
127
|
+
})
|
|
128
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
7
|
+
|
|
8
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
9
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const defaultPageSize = 20
|
|
13
|
+
|
|
14
|
+
// OlderTurnsPage is a page of history older than what the client already has.
|
|
15
|
+
type OlderTurnsPage struct {
|
|
16
|
+
Turns []ahptypes.Turn
|
|
17
|
+
NextCursor string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func fullBranchTurns(store SessionStore, prefix string) []ahptypes.Turn {
|
|
21
|
+
return RebuildHistory(store.Branch(""), RebuildOptions{TurnIDPrefix: prefix}).Turns
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func indexOfTurn(turns []ahptypes.Turn, id string) int {
|
|
25
|
+
for i, t := range turns {
|
|
26
|
+
if t.Id == id {
|
|
27
|
+
return i
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return -1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// InitialTurnsCursor is the cursor a client needs to fetch what precedes the visible window, ""
|
|
34
|
+
// when the window already starts at the beginning. The cursor is the id of the oldest visible turn.
|
|
35
|
+
func InitialTurnsCursor(store SessionStore, prefix string, visible []ahptypes.Turn) string {
|
|
36
|
+
if len(visible) == 0 {
|
|
37
|
+
return ""
|
|
38
|
+
}
|
|
39
|
+
if indexOfTurn(fullBranchTurns(store, prefix), visible[0].Id) > 0 {
|
|
40
|
+
return visible[0].Id
|
|
41
|
+
}
|
|
42
|
+
return ""
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// LoadOlderTurns returns the page of turns ending just before cursor.
|
|
46
|
+
func LoadOlderTurns(store SessionStore, prefix, cursor string, limit int) OlderTurnsPage {
|
|
47
|
+
if cursor == "" {
|
|
48
|
+
return OlderTurnsPage{Turns: []ahptypes.Turn{}}
|
|
49
|
+
}
|
|
50
|
+
if limit <= 0 {
|
|
51
|
+
limit = defaultPageSize
|
|
52
|
+
}
|
|
53
|
+
full := fullBranchTurns(store, prefix)
|
|
54
|
+
boundary := indexOfTurn(full, cursor)
|
|
55
|
+
if boundary <= 0 {
|
|
56
|
+
return OlderTurnsPage{Turns: []ahptypes.Turn{}}
|
|
57
|
+
}
|
|
58
|
+
start := boundary - limit
|
|
59
|
+
if start < 0 {
|
|
60
|
+
start = 0
|
|
61
|
+
}
|
|
62
|
+
page := OlderTurnsPage{Turns: append([]ahptypes.Turn{}, full[start:boundary]...)}
|
|
63
|
+
// More remains only if this page did not reach the beginning.
|
|
64
|
+
if start > 0 && len(page.Turns) > 0 {
|
|
65
|
+
page.NextCursor = page.Turns[0].Id
|
|
66
|
+
}
|
|
67
|
+
return page
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// DispatchOlderTurns serves fetchTurns: the page is dispatched to the chat before it returns.
|
|
71
|
+
func DispatchOlderTurns(h *host.Host, channel string, source HistorySource, cursor *string) error {
|
|
72
|
+
state := h.Store().Chat(channel)
|
|
73
|
+
if cursor != nil {
|
|
74
|
+
var next string
|
|
75
|
+
if state != nil && state.TurnsNextCursor != nil {
|
|
76
|
+
next = *state.TurnsNextCursor
|
|
77
|
+
}
|
|
78
|
+
if state == nil || state.TurnsNextCursor == nil || *cursor != next {
|
|
79
|
+
return wire.InvalidParams(fmt.Sprintf("Unrecognised fetchTurns cursor for %s", channel))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
c := ""
|
|
83
|
+
if cursor != nil {
|
|
84
|
+
c = *cursor
|
|
85
|
+
}
|
|
86
|
+
page := LoadOlderTurns(source.Store(), source.ID(), c, 0)
|
|
87
|
+
// Dispatched even when empty: it is what clears a cursor that has no more history behind it, so
|
|
88
|
+
// the client stops asking.
|
|
89
|
+
action := &ahptypes.ChatTurnsLoadedAction{Type: ahptypes.ActionTypeChatTurnsLoaded, Turns: page.Turns}
|
|
90
|
+
if page.NextCursor != "" {
|
|
91
|
+
action.TurnsNextCursor = &page.NextCursor
|
|
92
|
+
}
|
|
93
|
+
h.DispatchServerAction(channel, ahptypes.StateAction{Value: action})
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"reflect"
|
|
10
|
+
"regexp"
|
|
11
|
+
"sort"
|
|
12
|
+
"strconv"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"testing"
|
|
16
|
+
|
|
17
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
18
|
+
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
20
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
21
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
22
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
|
|
23
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
24
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
25
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// Twins of upstream test/fetch-turns.test.ts and test/truncate.test.ts.
|
|
29
|
+
|
|
30
|
+
// sessionFileFixture is a host serving one pre-written session file.
|
|
31
|
+
type sessionFileFixture struct {
|
|
32
|
+
t *testing.T
|
|
33
|
+
host *host.Host
|
|
34
|
+
client *testkit.Client
|
|
35
|
+
sessionID string
|
|
36
|
+
root string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (f *sessionFileFixture) chat() string { return wire.ChatURI(f.sessionID) }
|
|
40
|
+
func (f *sessionFileFixture) session() string { return wire.SessionURI(f.sessionID) }
|
|
41
|
+
|
|
42
|
+
func startSessionFile(t *testing.T, write func(root, id, cwd string), backend pi.Backend) *sessionFileFixture {
|
|
43
|
+
t.Helper()
|
|
44
|
+
f := &sessionFileFixture{t: t, root: t.TempDir(), sessionID: newID()}
|
|
45
|
+
workspace := t.TempDir()
|
|
46
|
+
write(f.root, f.sessionID, workspace)
|
|
47
|
+
opts := pi.ServicesOptions{
|
|
48
|
+
SessionRoot: f.root, DefaultWorkingDirectory: workspace,
|
|
49
|
+
CreateSessionManager: pi.PersistentStorage(filepath.Join(f.root, "created")),
|
|
50
|
+
}
|
|
51
|
+
f.host = testkit.NewHost(host.Options{})
|
|
52
|
+
opts.Host = f.host
|
|
53
|
+
if backend != nil {
|
|
54
|
+
opts.CreateBackend = func(*pi.LiveSession) (pi.Backend, error) { return backend, nil }
|
|
55
|
+
}
|
|
56
|
+
services := pi.NewServices(opts)
|
|
57
|
+
f.host.Serve(services.Capabilities())
|
|
58
|
+
f.client = testkit.Connect(t, f.host)
|
|
59
|
+
t.Cleanup(f.client.Close)
|
|
60
|
+
f.client.Initialize("paging-client", nil)
|
|
61
|
+
return f
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// jsonl builds a session file body: header + entries linked by id/parentId.
|
|
65
|
+
type jsonl struct {
|
|
66
|
+
lines []string
|
|
67
|
+
parent any
|
|
68
|
+
n int
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func newJSONL(id, cwd string) *jsonl {
|
|
72
|
+
raw, _ := json.Marshal(obj{"type": "session", "id": id, "parentId": nil, "timestamp": "2026-01-01T00:00:00.000Z", "version": 3, "cwd": cwd})
|
|
73
|
+
return &jsonl{lines: []string{string(raw)}}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (j *jsonl) push(entry obj) string {
|
|
77
|
+
j.n++
|
|
78
|
+
id := "n" + strconv.Itoa(j.n)
|
|
79
|
+
entry["id"], entry["parentId"], entry["timestamp"] = id, j.parent, "2026-01-01T00:00:00.000Z"
|
|
80
|
+
raw, _ := json.Marshal(entry)
|
|
81
|
+
j.lines = append(j.lines, string(raw))
|
|
82
|
+
j.parent = id
|
|
83
|
+
return id
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (j *jsonl) message(role string, content any) string {
|
|
87
|
+
return j.push(obj{"type": "message", "message": obj{"role": role, "content": content, "timestamp": 0}})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func (j *jsonl) write(t testing.TB, root, id, cwd string) {
|
|
91
|
+
dir := fixtureSessionDirectory(t, root, cwd)
|
|
92
|
+
if err := os.WriteFile(filepath.Join(dir, "2026-01-01T00-00-00-000Z_"+id+".jsonl"), []byte(strings.Join(j.lines, "\n")+"\n"), 0o644); err != nil {
|
|
93
|
+
t.Fatal(err)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// writeCompactedSession writes a session with `before` exchanges, then a compaction, then `after`.
|
|
98
|
+
// The compaction is what makes the earlier turns invisible to the default window, which is exactly
|
|
99
|
+
// the case paging exists for.
|
|
100
|
+
func writeCompactedSession(before, after int) func(root, id, cwd string) {
|
|
101
|
+
return func(root, id, cwd string) {
|
|
102
|
+
j := newJSONL(id, cwd)
|
|
103
|
+
exchange := func(n int) {
|
|
104
|
+
j.message("user", fmt.Sprintf("question %d", n))
|
|
105
|
+
j.message("assistant", []any{obj{"type": "text", "text": fmt.Sprintf("answer %d", n)}})
|
|
106
|
+
}
|
|
107
|
+
for i := 0; i < before; i++ {
|
|
108
|
+
exchange(i)
|
|
109
|
+
}
|
|
110
|
+
// The compaction keeps nothing before itself: firstKeptEntryId points at the entry that
|
|
111
|
+
// follows it, so the window starts here.
|
|
112
|
+
j.push(obj{"type": "compaction", "summary": "earlier work", "firstKeptEntryId": "none", "tokensBefore": 1000})
|
|
113
|
+
for i := 0; i < after; i++ {
|
|
114
|
+
exchange(before + i)
|
|
115
|
+
}
|
|
116
|
+
j.write(t0{}, root, id, cwd)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// t0 lets the writers above (called without a *testing.T) fail loudly.
|
|
121
|
+
type t0 struct{ testing.TB }
|
|
122
|
+
|
|
123
|
+
func (t0) Helper() {}
|
|
124
|
+
func (t0) Fatal(args ...any) { panic(fmt.Sprint(args...)) }
|
|
125
|
+
func (t0) Fatalf(format string, args ...any) { panic(fmt.Sprintf(format, args...)) }
|
|
126
|
+
|
|
127
|
+
func TestFetchTurns(t *testing.T) {
|
|
128
|
+
// 25 pre-compaction exchanges, so a 20-turn page leaves a second one.
|
|
129
|
+
f := startSessionFile(t, writeCompactedSession(25, 2), nil)
|
|
130
|
+
f.client.Subscribe(f.chat())
|
|
131
|
+
state := func() *ahptypes.ChatState { return f.host.Store().Chat(f.chat()) }
|
|
132
|
+
|
|
133
|
+
twin.Run(t, "fetch-turns", "pages backward while preserving a complete oldest-first transcript", func(t *testing.T) {
|
|
134
|
+
initial := state()
|
|
135
|
+
// Its presence is the protocol's signal that turns is a tail window.
|
|
136
|
+
if initial.TurnsNextCursor == nil {
|
|
137
|
+
t.Fatal("a compacted session must offer more history")
|
|
138
|
+
}
|
|
139
|
+
testkit.AssertValid(t, "state", "ChatState", initial)
|
|
140
|
+
newest := initial.Turns[len(initial.Turns)-1].Id
|
|
141
|
+
initialCount := len(initial.Turns)
|
|
142
|
+
|
|
143
|
+
f.client.Must("fetchTurns", obj{"channel": f.chat(), "cursor": *initial.TurnsNextCursor})
|
|
144
|
+
current := state()
|
|
145
|
+
if len(current.Turns) <= initialCount {
|
|
146
|
+
t.Fatalf("no older turns arrived: %d", len(current.Turns))
|
|
147
|
+
}
|
|
148
|
+
if got := current.Turns[len(current.Turns)-1].Id; got != newest {
|
|
149
|
+
t.Fatalf("paging must preserve the visible tail: %s vs %s", got, newest)
|
|
150
|
+
}
|
|
151
|
+
if !regexp.MustCompile(`question \d+`).MatchString(current.Turns[0].Message.Text) {
|
|
152
|
+
t.Fatalf("oldest turn = %q", current.Turns[0].Message.Text)
|
|
153
|
+
}
|
|
154
|
+
for current.TurnsNextCursor != nil {
|
|
155
|
+
f.client.Must("fetchTurns", obj{"channel": f.chat(), "cursor": *current.TurnsNextCursor})
|
|
156
|
+
current = state()
|
|
157
|
+
}
|
|
158
|
+
var numbered []int
|
|
159
|
+
re := regexp.MustCompile(`question (\d+)`)
|
|
160
|
+
for _, turn := range current.Turns {
|
|
161
|
+
if m := re.FindStringSubmatch(turn.Message.Text); m != nil {
|
|
162
|
+
n, _ := strconv.Atoi(m[1])
|
|
163
|
+
numbered = append(numbered, n)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if !sort.IntsAreSorted(numbered) || len(numbered) != 27 {
|
|
167
|
+
t.Fatalf("questions = %v", numbered)
|
|
168
|
+
}
|
|
169
|
+
// Without this the client would keep asking for pages that do not exist.
|
|
170
|
+
if current.TurnsNextCursor != nil {
|
|
171
|
+
t.Fatal("the cursor must clear once history is exhausted")
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
twin.Run(t, "fetch-turns", "rejects a session channel for this chat-scoped command", func(t *testing.T) {
|
|
176
|
+
f.client.ExpectError("fetchTurns", obj{"channel": f.session()}, wire.CodeInvalidParams)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
twin.Run(t, "fetch-turns", "rejects a cursor it did not issue", func(t *testing.T) {
|
|
180
|
+
f.client.ExpectError("fetchTurns", obj{"channel": f.chat(), "cursor": "made-up"}, -32602)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
twin.Run(t, "fetch-turns", "offers no cursor for a session that was never compacted", func(t *testing.T) {
|
|
184
|
+
fresh := startSessionFile(t, writeCompactedSession(0, 3), nil)
|
|
185
|
+
chat := snapshotState[ahptypes.ChatState](t, fresh.client, fresh.chat())
|
|
186
|
+
if chat.TurnsNextCursor != nil {
|
|
187
|
+
t.Fatalf("turnsNextCursor = %v", *chat.TurnsNextCursor)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// truncatingBackend records the entries it was asked to move back to.
|
|
193
|
+
type truncatingBackend struct {
|
|
194
|
+
mu sync.Mutex
|
|
195
|
+
truncated []string
|
|
196
|
+
accept bool
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func (b *truncatingBackend) Subscribe(func(mapper.Event)) func() { return func() {} }
|
|
200
|
+
func (b *truncatingBackend) Prompt(context.Context, string, []mapper.Image) error { return nil }
|
|
201
|
+
func (b *truncatingBackend) Steer(context.Context, string, []mapper.Image) error { return nil }
|
|
202
|
+
func (b *truncatingBackend) Abort(context.Context) error { return nil }
|
|
203
|
+
func (b *truncatingBackend) Truncate(_ context.Context, entryID string) (bool, error) {
|
|
204
|
+
b.mu.Lock()
|
|
205
|
+
defer b.mu.Unlock()
|
|
206
|
+
b.truncated = append(b.truncated, entryID)
|
|
207
|
+
return b.accept, nil
|
|
208
|
+
}
|
|
209
|
+
func (b *truncatingBackend) list() []string {
|
|
210
|
+
b.mu.Lock()
|
|
211
|
+
defer b.mu.Unlock()
|
|
212
|
+
return append([]string(nil), b.truncated...)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// writeTwoTurns is upstream truncate.test.ts's writeSession: two complete turns.
|
|
216
|
+
func writeTwoTurns(root, id, cwd string) {
|
|
217
|
+
j := newJSONL(id, cwd)
|
|
218
|
+
j.message("user", "first question")
|
|
219
|
+
j.message("assistant", []any{obj{"type": "text", "text": "first answer"}})
|
|
220
|
+
j.message("user", "second question")
|
|
221
|
+
j.message("assistant", []any{obj{"type": "text", "text": "second answer"}})
|
|
222
|
+
j.write(t0{}, root, id, cwd)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func TestTruncationAnchors(t *testing.T) {
|
|
226
|
+
entry := func(id, role, text string) pisession.Entry {
|
|
227
|
+
return pisession.Entry{"type": "message", "id": id, "parentId": nil, "timestamp": "2026-01-01T00:00:00.000Z", "message": obj{"role": role, "content": text}}
|
|
228
|
+
}
|
|
229
|
+
twin.Run(t, "truncate", "anchors a turn on its last entry, not its first", func(t *testing.T) {
|
|
230
|
+
// navigateTree is inclusive for a non-user entry and exclusive for a user one, so pointing
|
|
231
|
+
// at the turn's *last* entry is what expresses "keep turns up to and including this one".
|
|
232
|
+
history := pi.RebuildHistory([]pisession.Entry{entry("u1", "user", "q1"), entry("a1", "assistant", "a1"), entry("u2", "user", "q2"), entry("a2", "assistant", "a2")}, pi.RebuildOptions{TurnIDPrefix: "s"})
|
|
233
|
+
if len(history.Turns) != 2 {
|
|
234
|
+
t.Fatalf("%d turns", len(history.Turns))
|
|
235
|
+
}
|
|
236
|
+
if history.Anchors[history.Turns[0].Id] != "a1" || history.Anchors[history.Turns[1].Id] != "a2" {
|
|
237
|
+
t.Fatalf("anchors = %v", history.Anchors)
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
twin.Run(t, "truncate", "anchors 'clear everything' on the first user entry", func(t *testing.T) {
|
|
242
|
+
// Navigating to a user entry lands the leaf on its parent, and the first one's parent is
|
|
243
|
+
// null, which is how Pi expresses an empty branch.
|
|
244
|
+
u1 := entry("u1", "user", "q")
|
|
245
|
+
a1 := entry("a1", "assistant", "a")
|
|
246
|
+
a1["parentId"] = "u1"
|
|
247
|
+
history := pi.RebuildHistory([]pisession.Entry{u1, a1}, pi.RebuildOptions{TurnIDPrefix: "s"})
|
|
248
|
+
if history.Anchors[pi.ClearAllAnchor] != "u1" {
|
|
249
|
+
t.Fatalf("anchors = %v", history.Anchors)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func TestChatTruncated(t *testing.T) {
|
|
255
|
+
twin.Run(t, "truncate", "moves pi's leaf to the named turn, not just the client's view", func(t *testing.T) {
|
|
256
|
+
backend := &truncatingBackend{accept: true}
|
|
257
|
+
f := startSessionFile(t, writeTwoTurns, backend)
|
|
258
|
+
chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
|
|
259
|
+
if len(chat.Turns) != 2 {
|
|
260
|
+
t.Fatalf("%d turns", len(chat.Turns))
|
|
261
|
+
}
|
|
262
|
+
f.client.Dispatch(f.chat(), obj{"type": "chat/truncated", "turnId": chat.Turns[0].Id})
|
|
263
|
+
testkit.Eventually(t, "pi's history to receive the truncation", func() bool { return len(backend.list()) == 1 })
|
|
264
|
+
// Both halves have to move: the reducer drops the later turn, and Pi is told to branch from
|
|
265
|
+
// the kept one.
|
|
266
|
+
if got := len(f.host.Store().Chat(f.chat()).Turns); got != 1 {
|
|
267
|
+
t.Fatalf("%d turns remain", got)
|
|
268
|
+
}
|
|
269
|
+
// The entry Pi is told to move to is the kept turn's last entry (the first answer).
|
|
270
|
+
file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
|
|
271
|
+
m, _ := pisession.Open(file)
|
|
272
|
+
var assistantIDs []string
|
|
273
|
+
for _, e := range m.Entries() {
|
|
274
|
+
if e.Role() == "assistant" {
|
|
275
|
+
assistantIDs = append(assistantIDs, e.ID())
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if !reflect.DeepEqual(backend.list(), assistantIDs[:1]) {
|
|
279
|
+
t.Fatalf("truncated to %v, want %v", backend.list(), assistantIDs[:1])
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
twin.Run(t, "truncate", "maps 'clear everything' onto the first entry", func(t *testing.T) {
|
|
284
|
+
backend := &truncatingBackend{accept: true}
|
|
285
|
+
f := startSessionFile(t, writeTwoTurns, backend)
|
|
286
|
+
f.client.Subscribe(f.chat())
|
|
287
|
+
f.client.Dispatch(f.chat(), obj{"type": "chat/truncated"})
|
|
288
|
+
testkit.Eventually(t, "pi's history to receive the clear-all truncation", func() bool { return len(backend.list()) == 1 })
|
|
289
|
+
if got := len(f.host.Store().Chat(f.chat()).Turns); got != 0 {
|
|
290
|
+
t.Fatalf("%d turns remain", got)
|
|
291
|
+
}
|
|
292
|
+
file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
|
|
293
|
+
m, _ := pisession.Open(file)
|
|
294
|
+
if first := m.Entries()[0].ID(); !reflect.DeepEqual(backend.list(), []string{first}) {
|
|
295
|
+
t.Fatalf("truncated to %v, want the first entry %s", backend.list(), first)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
twin.Run(t, "truncate", "refuses a turn it cannot anchor, instead of diverging", func(t *testing.T) {
|
|
300
|
+
backend := &truncatingBackend{accept: true}
|
|
301
|
+
f := startSessionFile(t, writeTwoTurns, backend)
|
|
302
|
+
before := len(snapshotState[ahptypes.ChatState](t, f.client, f.chat()).Turns)
|
|
303
|
+
mark := len(f.client.Notifications())
|
|
304
|
+
f.client.Dispatch(f.chat(), obj{"type": "chat/truncated", "turnId": "no-such-turn"})
|
|
305
|
+
// Refused before the reducer runs: accepting would truncate the client's view while Pi
|
|
306
|
+
// kept everything.
|
|
307
|
+
n, ok := f.client.Await(func(n testkit.Notification) bool { _, isAction := n.Envelope(); return isAction }, testkit.Timeout)
|
|
308
|
+
_ = mark
|
|
309
|
+
if !ok {
|
|
310
|
+
t.Fatal("no action envelope")
|
|
311
|
+
}
|
|
312
|
+
env, _ := n.Envelope()
|
|
313
|
+
if env.RejectionReason == nil || !strings.Contains(*env.RejectionReason, "Cannot truncate") {
|
|
314
|
+
t.Fatalf("rejectionReason = %v", env.RejectionReason)
|
|
315
|
+
}
|
|
316
|
+
if got := len(f.host.Store().Chat(f.chat()).Turns); got != before {
|
|
317
|
+
t.Fatalf("%d turns, want %d", got, before)
|
|
318
|
+
}
|
|
319
|
+
if got := backend.list(); len(got) != 0 {
|
|
320
|
+
t.Fatalf("truncated = %v", got)
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
}
|