@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,416 @@
|
|
|
1
|
+
package pi_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"reflect"
|
|
7
|
+
"strings"
|
|
8
|
+
"sync"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
12
|
+
|
|
13
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
14
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
15
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pi"
|
|
16
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
|
|
17
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
|
|
18
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/twin"
|
|
19
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/wire"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Twins of upstream test/chat-driver.test.ts: the chat driver end to end: a client turn reaching a
|
|
23
|
+
// backend, streamed output coming back as actions, and queued-message consumption. The backend
|
|
24
|
+
// is a scripted fake rather than a real agent: the point is the host's turn arbitration.
|
|
25
|
+
|
|
26
|
+
func scripted(t *testing.T) *scriptedBackend {
|
|
27
|
+
return newScriptedBackend(func(text string) []mapper.Event {
|
|
28
|
+
if text == "long running" {
|
|
29
|
+
return nil
|
|
30
|
+
}
|
|
31
|
+
return say(t, "echo: "+text)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func turnCount(f *fixture, n int) func() bool {
|
|
36
|
+
return func() bool { return len(f.chat().Turns) == n }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestChatDriver(t *testing.T) {
|
|
40
|
+
twin.Run(t, "chat-driver", "creates the session's default chat and points defaultChat at it", func(t *testing.T) {
|
|
41
|
+
f := startFixture(t, scripted(t))
|
|
42
|
+
session := f.session()
|
|
43
|
+
if session.Lifecycle != ahptypes.SessionLifecycleReady {
|
|
44
|
+
t.Fatalf("lifecycle = %s", session.Lifecycle)
|
|
45
|
+
}
|
|
46
|
+
if len(session.Chats) != 1 || session.Chats[0].Resource != f.chatChannel {
|
|
47
|
+
t.Fatalf("chats = %+v", session.Chats)
|
|
48
|
+
}
|
|
49
|
+
if session.DefaultChat == nil || *session.DefaultChat != f.chatChannel {
|
|
50
|
+
t.Fatalf("defaultChat = %v", session.DefaultChat)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
twin.Run(t, "chat-driver", "runs a client turn through the backend and streams the reply back", func(t *testing.T) {
|
|
55
|
+
b := scripted(t)
|
|
56
|
+
f := startFixture(t, b)
|
|
57
|
+
f.turnStarted("t1", userMessage("hello"))
|
|
58
|
+
testkit.Eventually(t, "the streamed turn to complete", turnCount(f, 1))
|
|
59
|
+
if got := b.promptList(); !reflect.DeepEqual(got, []string{"hello"}) {
|
|
60
|
+
t.Fatalf("prompts = %v", got)
|
|
61
|
+
}
|
|
62
|
+
turn := f.chat().Turns[0]
|
|
63
|
+
if turn.State != ahptypes.TurnStateComplete {
|
|
64
|
+
t.Fatalf("state = %s", turn.State)
|
|
65
|
+
}
|
|
66
|
+
part, ok := turn.ResponseParts[0].Value.(*ahptypes.MarkdownResponsePart)
|
|
67
|
+
if !ok || part.Content != "echo: hello" {
|
|
68
|
+
t.Fatalf("first part = %+v", turn.ResponseParts[0].Value)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
twin.Run(t, "chat-driver", "adapts supported attachments into pi prompt text", func(t *testing.T) {
|
|
73
|
+
b := scripted(t)
|
|
74
|
+
f := startFixture(t, b)
|
|
75
|
+
text := "inspect the path from the client"
|
|
76
|
+
expected := text + "\n\n/outside.ts\n\nselected context\n\nembedded context"
|
|
77
|
+
message := userMessage(text)
|
|
78
|
+
message["attachments"] = []any{
|
|
79
|
+
obj{"type": "resource", "label": "outside.ts", "uri": "file:///outside.ts"},
|
|
80
|
+
obj{"type": "simple", "label": "selection", "modelRepresentation": "selected context"},
|
|
81
|
+
embeddedText("embedded context", "note.txt"),
|
|
82
|
+
embeddedImage("screenshot.png"),
|
|
83
|
+
}
|
|
84
|
+
f.turnStarted("t-path", message)
|
|
85
|
+
testkit.Eventually(t, "the attachment-expanded prompt to reach the backend", func() bool {
|
|
86
|
+
for _, p := range b.promptList() {
|
|
87
|
+
if p == expected {
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
})
|
|
93
|
+
b.mu.Lock()
|
|
94
|
+
defer b.mu.Unlock()
|
|
95
|
+
if got := b.prompts[len(b.prompts)-1]; got != expected {
|
|
96
|
+
t.Fatalf("prompt = %q", got)
|
|
97
|
+
}
|
|
98
|
+
want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}
|
|
99
|
+
if got := b.promptImages[len(b.promptImages)-1]; !reflect.DeepEqual(got, want) {
|
|
100
|
+
t.Fatalf("images = %+v", got)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
twin.Run(t, "chat-driver", "keeps the session catalog's chat summary in step", func(t *testing.T) {
|
|
105
|
+
f := startFixture(t, scripted(t))
|
|
106
|
+
f.turnStarted("t-summary", userMessage("update the summary"))
|
|
107
|
+
testkit.Eventually(t, "the summary-driving turn to complete", turnCount(f, 1))
|
|
108
|
+
testkit.Eventually(t, "the session summary to follow the chat", func() bool {
|
|
109
|
+
s, c := f.session(), f.chat()
|
|
110
|
+
return s.Chats[0].Status == c.Status && s.Chats[0].ModifiedAt == c.ModifiedAt
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
twin.Run(t, "chat-driver", "forwards steering text and images to the active backend", func(t *testing.T) {
|
|
115
|
+
b := scripted(t)
|
|
116
|
+
f := startFixture(t, b)
|
|
117
|
+
f.turnStarted("t-steering", userMessage("long running"))
|
|
118
|
+
testkit.Eventually(t, "the steerable prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
|
|
119
|
+
steer := userMessage("focus on tests")
|
|
120
|
+
steer["attachments"] = []any{
|
|
121
|
+
obj{"type": "simple", "label": "context", "modelRepresentation": "steering context"},
|
|
122
|
+
embeddedImage("steering.png"),
|
|
123
|
+
}
|
|
124
|
+
f.pending("steering", "steer-1", steer)
|
|
125
|
+
testkit.Eventually(t, "the steering message to reach the backend", func() bool {
|
|
126
|
+
b.mu.Lock()
|
|
127
|
+
defer b.mu.Unlock()
|
|
128
|
+
return len(b.steers) == 1
|
|
129
|
+
})
|
|
130
|
+
b.mu.Lock()
|
|
131
|
+
defer b.mu.Unlock()
|
|
132
|
+
if !reflect.DeepEqual(b.steers, []string{"focus on tests\n\nsteering context"}) {
|
|
133
|
+
t.Fatalf("steers = %v", b.steers)
|
|
134
|
+
}
|
|
135
|
+
if want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}; !reflect.DeepEqual(b.steerImages[0], want) {
|
|
136
|
+
t.Fatalf("images = %+v", b.steerImages[0])
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
twin.Run(t, "chat-driver", "consumes a queued message as its own turn once the chat goes idle", func(t *testing.T) {
|
|
141
|
+
// Queued messages never reach pi: the protocol's own state is the queue, and the host
|
|
142
|
+
// starts a fresh turn for the head entry when idle.
|
|
143
|
+
b := scripted(t)
|
|
144
|
+
f := startFixture(t, b)
|
|
145
|
+
message := userMessage("then do this")
|
|
146
|
+
message["attachments"] = []any{embeddedText("queued context", "queued.txt"), embeddedImage("queued.png")}
|
|
147
|
+
f.pending("queued", "q-1", message)
|
|
148
|
+
testkit.Eventually(t, "the queued prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
|
|
149
|
+
testkit.Eventually(t, "the queued turn to complete", func() bool {
|
|
150
|
+
c := f.chat()
|
|
151
|
+
return len(c.Turns) == 1 && c.Turns[0].State == ahptypes.TurnStateComplete
|
|
152
|
+
})
|
|
153
|
+
b.mu.Lock()
|
|
154
|
+
if got := b.prompts[len(b.prompts)-1]; got != "then do this\n\nqueued context" {
|
|
155
|
+
t.Fatalf("prompt = %q", got)
|
|
156
|
+
}
|
|
157
|
+
if want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}; !reflect.DeepEqual(b.promptImages[0], want) {
|
|
158
|
+
t.Fatalf("images = %+v", b.promptImages[0])
|
|
159
|
+
}
|
|
160
|
+
b.mu.Unlock()
|
|
161
|
+
// The reducer removes the entry atomically with creating the turn, so a client can never
|
|
162
|
+
// see it both queued and running.
|
|
163
|
+
if q := f.chat().QueuedMessages; len(q) != 0 {
|
|
164
|
+
t.Fatalf("queuedMessages = %+v", q)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
twin.Run(t, "chat-driver", "holds a queued message until the active turn settles", func(t *testing.T) {
|
|
169
|
+
b := scripted(t)
|
|
170
|
+
f := startFixture(t, b)
|
|
171
|
+
f.turnStarted("t-blocking", userMessage("long running"))
|
|
172
|
+
testkit.Eventually(t, "the active prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
|
|
173
|
+
f.pending("queued", "q-after-active", userMessage("run after"))
|
|
174
|
+
f.client.Ping()
|
|
175
|
+
waiting := f.chat()
|
|
176
|
+
if len(b.promptList()) != 1 {
|
|
177
|
+
t.Fatal("queued message prompted before the active turn settled")
|
|
178
|
+
}
|
|
179
|
+
if waiting.ActiveTurn == nil || waiting.ActiveTurn.Id != "t-blocking" {
|
|
180
|
+
t.Fatalf("activeTurn = %+v", waiting.ActiveTurn)
|
|
181
|
+
}
|
|
182
|
+
if len(waiting.QueuedMessages) == 0 || waiting.QueuedMessages[0].Id != "q-after-active" {
|
|
183
|
+
t.Fatalf("queued = %+v", waiting.QueuedMessages)
|
|
184
|
+
}
|
|
185
|
+
b.emit(event(t, `{"type":"agent_settled"}`))
|
|
186
|
+
testkit.Eventually(t, "the queued message to start after settlement", func() bool { return len(b.promptList()) == 2 })
|
|
187
|
+
testkit.Eventually(t, "the queued turn to complete", func() bool {
|
|
188
|
+
c := f.chat()
|
|
189
|
+
return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete && len(c.QueuedMessages) == 0
|
|
190
|
+
})
|
|
191
|
+
completed := f.chat()
|
|
192
|
+
if got := b.promptList(); got[len(got)-1] != "run after" {
|
|
193
|
+
t.Fatalf("prompts = %v", got)
|
|
194
|
+
}
|
|
195
|
+
if completed.Turns[len(completed.Turns)-1].Message.Text != "run after" {
|
|
196
|
+
t.Fatalf("turns = %+v", turnShapes(completed.Turns))
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
twin.Run(t, "chat-driver", "does not start a prompt cancelled during model selection", func(t *testing.T) {
|
|
201
|
+
b := scripted(t)
|
|
202
|
+
b.selectionGate = newRelease()
|
|
203
|
+
defer b.selectionGate.open()
|
|
204
|
+
f := startFixture(t, b)
|
|
205
|
+
message := userMessage("do not run")
|
|
206
|
+
message["model"] = obj{"id": "pi/test-model"}
|
|
207
|
+
f.turnStarted("t-select-cancel", message)
|
|
208
|
+
testkit.Eventually(t, "model selection to start", func() bool { return b.count(func(b *scriptedBackend) int { return b.selections }) == 1 })
|
|
209
|
+
f.pending("queued", "after-select-cancel", userMessage("run after selection cancellation"))
|
|
210
|
+
f.cancel("t-select-cancel")
|
|
211
|
+
testkit.Eventually(t, "preflight cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
|
|
212
|
+
|
|
213
|
+
b.selectionGate.open()
|
|
214
|
+
testkit.Eventually(t, "queued work to complete after cancelled preflight settles", turnCount(f, 2))
|
|
215
|
+
if got := b.promptList(); !reflect.DeepEqual(got, []string{"run after selection cancellation"}) {
|
|
216
|
+
t.Fatalf("prompts = %v", got)
|
|
217
|
+
}
|
|
218
|
+
completed := f.chat()
|
|
219
|
+
want := []turnShape{
|
|
220
|
+
{"t-select-cancel", "do not run", ahptypes.TurnStateCancelled},
|
|
221
|
+
{"turn-after-select-cancel", "run after selection cancellation", ahptypes.TurnStateComplete},
|
|
222
|
+
}
|
|
223
|
+
testkit.Eventually(t, "the queued turn to complete", func() bool { return f.chat().Turns[1].State == ahptypes.TurnStateComplete })
|
|
224
|
+
completed = f.chat()
|
|
225
|
+
if got := turnShapes(completed.Turns); !reflect.DeepEqual(got, want) {
|
|
226
|
+
t.Fatalf("turns = %+v, want %+v", got, want)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
before := turnShapes(completed.Turns)
|
|
230
|
+
f.client.Dispatch(f.chatChannel, obj{"type": "chat/truncated", "turnId": "t-select-cancel"})
|
|
231
|
+
f.client.Ping()
|
|
232
|
+
if got := turnShapes(f.chat().Turns); !reflect.DeepEqual(got, before) {
|
|
233
|
+
t.Fatalf("a turn cancelled before persistence must not acquire a truncation anchor: %+v", got)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
twin.Run(t, "chat-driver", "does not run a prompt cancelled during backend preflight", func(t *testing.T) {
|
|
238
|
+
b := scripted(t)
|
|
239
|
+
b.promptGate = newRelease()
|
|
240
|
+
defer b.promptGate.open()
|
|
241
|
+
f := startFixture(t, b)
|
|
242
|
+
message := userMessage("do not run")
|
|
243
|
+
message["attachments"] = []any{embeddedImage("screenshot.png")}
|
|
244
|
+
f.turnStarted("t-preflight-cancel", message)
|
|
245
|
+
testkit.Eventually(t, "backend preflight to start", func() bool { return b.count(func(b *scriptedBackend) int { return b.promptCalls }) == 1 })
|
|
246
|
+
f.pending("queued", "after-preflight-cancel", userMessage("run after image preflight"))
|
|
247
|
+
f.cancel("t-preflight-cancel")
|
|
248
|
+
testkit.Eventually(t, "preflight cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
|
|
249
|
+
|
|
250
|
+
b.promptGate.open()
|
|
251
|
+
testkit.Eventually(t, "queued work to complete after backend preflight settles", func() bool {
|
|
252
|
+
c := f.chat()
|
|
253
|
+
return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete
|
|
254
|
+
})
|
|
255
|
+
if got := b.promptList(); !reflect.DeepEqual(got, []string{"run after image preflight"}) {
|
|
256
|
+
t.Fatalf("prompts = %v", got)
|
|
257
|
+
}
|
|
258
|
+
want := []turnShape{
|
|
259
|
+
{"t-preflight-cancel", "do not run", ahptypes.TurnStateCancelled},
|
|
260
|
+
{"turn-after-preflight-cancel", "run after image preflight", ahptypes.TurnStateComplete},
|
|
261
|
+
}
|
|
262
|
+
if got := turnShapes(f.chat().Turns); !reflect.DeepEqual(got, want) {
|
|
263
|
+
t.Fatalf("turns = %+v, want %+v", got, want)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
twin.Run(t, "chat-driver", "anchors a cancelled turn before resuming queued work", func(t *testing.T) {
|
|
268
|
+
b := scripted(t)
|
|
269
|
+
f := startFixture(t, b)
|
|
270
|
+
f.turnStarted("t-cancel", userMessage("long running"))
|
|
271
|
+
testkit.Eventually(t, "the cancellable prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
|
|
272
|
+
if a := f.chat().ActiveTurn; a == nil || a.Id != "t-cancel" {
|
|
273
|
+
t.Fatalf("activeTurn = %+v", a)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
live, ok := f.sessions.Get(f.sessionChannel)
|
|
277
|
+
if !ok {
|
|
278
|
+
t.Fatal("no live session")
|
|
279
|
+
}
|
|
280
|
+
// AgentSession notifies listeners, then records the same user message.
|
|
281
|
+
user := `{"role":"user","content":"long running","timestamp":0}`
|
|
282
|
+
b.emit(event(t, `{"type":"message_start","message":`+user+`}`))
|
|
283
|
+
b.emit(event(t, `{"type":"message_end","message":`+user+`}`))
|
|
284
|
+
manager := live.SessionManager.(*pisession.Manager)
|
|
285
|
+
if _, err := manager.AppendMessage(event(t, user)); err != nil {
|
|
286
|
+
t.Fatal(err)
|
|
287
|
+
}
|
|
288
|
+
if _, err := manager.AppendMessage(event(t, `{"role":"assistant","content":[],"stopReason":"aborted","timestamp":0}`)); err != nil {
|
|
289
|
+
t.Fatal(err)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
f.pending("queued", "after-cancel", userMessage("run after cancel"))
|
|
293
|
+
f.cancel("t-cancel")
|
|
294
|
+
testkit.Eventually(t, "cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
|
|
295
|
+
testkit.Eventually(t, "queued work to complete after backend cancellation", func() bool {
|
|
296
|
+
c := f.chat()
|
|
297
|
+
return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete
|
|
298
|
+
})
|
|
299
|
+
chat := f.chat()
|
|
300
|
+
if chat.Turns[0].Id != "t-cancel" || chat.Turns[0].State != ahptypes.TurnStateCancelled {
|
|
301
|
+
t.Fatalf("turns = %+v", turnShapes(chat.Turns))
|
|
302
|
+
}
|
|
303
|
+
if chat.Turns[1].Message.Text != "run after cancel" {
|
|
304
|
+
t.Fatalf("turns = %+v", turnShapes(chat.Turns))
|
|
305
|
+
}
|
|
306
|
+
testkit.Eventually(t, "the session summary to follow the chat", func() bool { return f.session().Chats[0].Status == f.chat().Status })
|
|
307
|
+
|
|
308
|
+
f.client.Dispatch(f.chatChannel, obj{"type": "chat/truncated", "turnId": "t-cancel"})
|
|
309
|
+
f.client.Ping()
|
|
310
|
+
var ids []string
|
|
311
|
+
for _, turn := range f.chat().Turns {
|
|
312
|
+
ids = append(ids, turn.Id)
|
|
313
|
+
}
|
|
314
|
+
if !reflect.DeepEqual(ids, []string{"t-cancel"}) {
|
|
315
|
+
t.Fatalf("a recorded cancelled turn must remain a valid truncation target; turns = %v", ids)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func TestChatDriverBackendFailure(t *testing.T) {
|
|
321
|
+
twin.Run(t, "chat-driver", "marks the session failed when the backend cannot start", func(t *testing.T) {
|
|
322
|
+
h, reg := startWith(t, pi.RegistryOptions{CreateBackend: func(*pi.LiveSession) (pi.Backend, error) {
|
|
323
|
+
return nil, errors.New("no credentials")
|
|
324
|
+
}})
|
|
325
|
+
uri := wire.SessionURI(newID())
|
|
326
|
+
if err := reg.Create(context.Background(), ahptypes.CreateSessionParams{Channel: uri}); err != nil {
|
|
327
|
+
t.Fatal(err)
|
|
328
|
+
}
|
|
329
|
+
testkit.Eventually(t, "backend startup failure to reach session state", func() bool {
|
|
330
|
+
return h.Store().Session(uri).Lifecycle != ahptypes.SessionLifecycleCreating
|
|
331
|
+
})
|
|
332
|
+
state := h.Store().Session(uri)
|
|
333
|
+
if state.Lifecycle != ahptypes.SessionLifecycleFailed {
|
|
334
|
+
t.Fatalf("lifecycle = %s", state.Lifecycle)
|
|
335
|
+
}
|
|
336
|
+
if state.CreationError == nil || !strings.Contains(state.CreationError.Message, "no credentials") {
|
|
337
|
+
t.Fatalf("creationError = %+v", state.CreationError)
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
twin.Run(t, "chat-driver", "closes the turn when prompt() rejects before any agent event", func(t *testing.T) {
|
|
342
|
+
backend := funcBackend{prompt: func(context.Context, string) error { return errors.New("model unavailable") }}
|
|
343
|
+
f := startFixture(t, backend)
|
|
344
|
+
// Dispatched through the wire so the reducer creates the active turn before the side
|
|
345
|
+
// effect runs: the same ordering production relies on.
|
|
346
|
+
f.turnStarted("t1", userMessage("hi"))
|
|
347
|
+
// Nothing will ever emit agent_settled, so without explicit handling the turn would stay
|
|
348
|
+
// active and the session stuck at InProgress.
|
|
349
|
+
testkit.Eventually(t, "the rejected prompt to close its turn", turnCount(f, 1))
|
|
350
|
+
state := f.chat()
|
|
351
|
+
if state.ActiveTurn != nil {
|
|
352
|
+
t.Fatalf("activeTurn = %+v", state.ActiveTurn)
|
|
353
|
+
}
|
|
354
|
+
turn := state.Turns[0]
|
|
355
|
+
if turn.State != ahptypes.TurnStateError {
|
|
356
|
+
t.Fatalf("state = %s", turn.State)
|
|
357
|
+
}
|
|
358
|
+
last, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart)
|
|
359
|
+
if !ok || !strings.Contains(last.Error.Message, "model unavailable") {
|
|
360
|
+
t.Fatalf("error part = %+v", turn.ResponseParts)
|
|
361
|
+
}
|
|
362
|
+
testkit.Eventually(t, "the session summary to follow the chat", func() bool {
|
|
363
|
+
return f.session().Chats[0].Status == f.chat().Status
|
|
364
|
+
})
|
|
365
|
+
overrides := f.sessions.CatalogueOverrides()
|
|
366
|
+
if len(overrides) == 0 || overrides[0].Summary.Status != f.chat().Status {
|
|
367
|
+
t.Fatalf("catalogue overrides = %+v", overrides)
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
twin.Run(t, "chat-driver", "clears the pending message once pi consumes it", func(t *testing.T) {
|
|
372
|
+
// The failure this covers looks like a hang: pi injects the steering message into the
|
|
373
|
+
// run, but ChatState.steeringMessage never clears, so the client shows it as forever
|
|
374
|
+
// unsent even though the model got it.
|
|
375
|
+
var mu sync.Mutex
|
|
376
|
+
steered := make(chan struct{})
|
|
377
|
+
var listeners []func(mapper.Event)
|
|
378
|
+
emit := func(literal string) {
|
|
379
|
+
mu.Lock()
|
|
380
|
+
ls := append([]func(mapper.Event){}, listeners...)
|
|
381
|
+
mu.Unlock()
|
|
382
|
+
for _, l := range ls {
|
|
383
|
+
l(event(t, literal))
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
backend := funcBackend{
|
|
387
|
+
subscribe: func(l func(mapper.Event)) func() {
|
|
388
|
+
mu.Lock()
|
|
389
|
+
listeners = append(listeners, l)
|
|
390
|
+
mu.Unlock()
|
|
391
|
+
return func() {}
|
|
392
|
+
},
|
|
393
|
+
prompt: func(context.Context, string) error { emit(`{"type":"agent_start"}`); return nil },
|
|
394
|
+
// pi acknowledges the message by growing its queue...
|
|
395
|
+
steer: func(context.Context, string) error {
|
|
396
|
+
emit(`{"type":"queue_update","steering":["focus on tests"],"followUp":[]}`)
|
|
397
|
+
close(steered)
|
|
398
|
+
return nil
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
f := startFixture(t, backend)
|
|
402
|
+
f.turnStarted("t1", userMessage("go"))
|
|
403
|
+
f.pending("steering", "steer-1", userMessage("focus on tests"))
|
|
404
|
+
testkit.Eventually(t, "the steering message to enter protocol state", func() bool { return f.chat().SteeringMessage != nil })
|
|
405
|
+
// The side effect runs on its own goroutine here (upstream's runs inline in the same
|
|
406
|
+
// tick), so wait for pi's acknowledgement before consuming.
|
|
407
|
+
<-steered
|
|
408
|
+
// ...and consumes it by shrinking the queue right before injecting.
|
|
409
|
+
emit(`{"type":"queue_update","steering":[],"followUp":[]}`)
|
|
410
|
+
testkit.Eventually(t, "the consumed steering message to leave protocol state", func() bool { return f.chat().SteeringMessage == nil })
|
|
411
|
+
// The text itself is not recorded here: pi delivers it as an ordinary user message, and
|
|
412
|
+
// the mapper turns that into its own turn. Only the pending slot is cleared by this path.
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
var _ = host.Options{}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
package pi
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
|
|
8
|
+
"github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
|
|
9
|
+
|
|
10
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/host"
|
|
11
|
+
"github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// unsupportedClientActionReason is the policy for client-dispatchable actions this host does not
|
|
15
|
+
// carry out. Refusing is the honest answer: silently accepting would move client state away from
|
|
16
|
+
// what the agent does.
|
|
17
|
+
func unsupportedClientActionReason(actionType string) string {
|
|
18
|
+
t := func(a ahptypes.ActionType) string { return string(a) }
|
|
19
|
+
switch actionType {
|
|
20
|
+
case t(ahptypes.ActionTypeSessionActiveClientSet), t(ahptypes.ActionTypeSessionActiveClientRemoved):
|
|
21
|
+
return "This host does not accept active clients"
|
|
22
|
+
case t(ahptypes.ActionTypeSessionWorkingDirectorySet), t(ahptypes.ActionTypeSessionWorkingDirectoryRemoved),
|
|
23
|
+
t(ahptypes.ActionTypeSessionWorkingDirectoryReplaced), t(ahptypes.ActionTypeChatWorkingDirectorySet),
|
|
24
|
+
t(ahptypes.ActionTypeChatWorkingDirectoryRemoved):
|
|
25
|
+
return "This agent does not support changing working directories"
|
|
26
|
+
case t(ahptypes.ActionTypeSessionCustomizationToggled):
|
|
27
|
+
return "This host does not support customizations"
|
|
28
|
+
case t(ahptypes.ActionTypeSessionMcpServerStartRequested), t(ahptypes.ActionTypeSessionMcpServerStopRequested),
|
|
29
|
+
// Not in the upstream policy: the pinned spec commit is newer than the npm 0.9.0 package
|
|
30
|
+
// pi-ahp was written against, and added these two client-dispatchable actions. Refused
|
|
31
|
+
// like their siblings (see PORT.md).
|
|
32
|
+
t(ahptypes.ActionTypeSessionMcpServerBackgroundRequested):
|
|
33
|
+
return "This host does not support MCP servers"
|
|
34
|
+
case t(ahptypes.ActionTypeSessionIsReadChanged), t(ahptypes.ActionTypeSessionIsArchivedChanged),
|
|
35
|
+
t(ahptypes.ActionTypeChatIsArchivedChanged): // chat/isArchivedChanged: newer than upstream's spec, see above
|
|
36
|
+
return "This host does not persist read or archive state"
|
|
37
|
+
case t(ahptypes.ActionTypeSessionConfigChanged):
|
|
38
|
+
return "This session has no mutable configuration"
|
|
39
|
+
case t(ahptypes.ActionTypeChatToolCallConfirmed), t(ahptypes.ActionTypeChatToolCallComplete),
|
|
40
|
+
t(ahptypes.ActionTypeChatToolCallResultConfirmed), t(ahptypes.ActionTypeChatToolCallContentChanged):
|
|
41
|
+
return "This host does not support client tool execution or confirmation"
|
|
42
|
+
case t(ahptypes.ActionTypeChatInputAnswerChanged), t(ahptypes.ActionTypeChatInputCompleted):
|
|
43
|
+
return "This host does not support interactive input requests"
|
|
44
|
+
case t(ahptypes.ActionTypeChatTurnResume):
|
|
45
|
+
return "This host cannot resume an errored turn"
|
|
46
|
+
}
|
|
47
|
+
return ""
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// userMessageRejectionReason is the message check plus the requirement that a client speaks as a
|
|
51
|
+
// user. The message is raw decoded JSON, so a malformed shape is reported rather than trusted.
|
|
52
|
+
func userMessageRejectionReason(message any, originReason string) string {
|
|
53
|
+
if reason := mapper.MessageRejectionReason(message); reason != "" {
|
|
54
|
+
return reason
|
|
55
|
+
}
|
|
56
|
+
m, _ := message.(map[string]any)
|
|
57
|
+
origin, _ := m["origin"].(map[string]any)
|
|
58
|
+
if origin["kind"] == string(ahptypes.MessageKindUser) {
|
|
59
|
+
return ""
|
|
60
|
+
}
|
|
61
|
+
return originReason
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func (r *Registry) validateClientAction(channel string, action host.ClientAction) string {
|
|
65
|
+
r.mu.Lock()
|
|
66
|
+
session := r.sessions[channel]
|
|
67
|
+
if session == nil {
|
|
68
|
+
session = r.byChat[channel]
|
|
69
|
+
}
|
|
70
|
+
_, channelDisposing := r.disposals[channel]
|
|
71
|
+
disposing := channelDisposing
|
|
72
|
+
if session != nil {
|
|
73
|
+
_, sessionDisposing := r.disposals[session.URI]
|
|
74
|
+
disposing = disposing || sessionDisposing
|
|
75
|
+
}
|
|
76
|
+
r.mu.Unlock()
|
|
77
|
+
if disposing {
|
|
78
|
+
return "This session is being disposed"
|
|
79
|
+
}
|
|
80
|
+
if reason := unsupportedClientActionReason(action.Type); reason != "" {
|
|
81
|
+
return reason
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
var raw map[string]any
|
|
85
|
+
dec := json.NewDecoder(bytes.NewReader(action.Raw))
|
|
86
|
+
dec.UseNumber()
|
|
87
|
+
_ = dec.Decode(&raw)
|
|
88
|
+
chat := r.host.Store().Chat(channel)
|
|
89
|
+
str := func(key string) (string, bool) { s, ok := raw[key].(string); return s, ok }
|
|
90
|
+
|
|
91
|
+
switch action.Type {
|
|
92
|
+
case string(ahptypes.ActionTypeSessionTitleChanged):
|
|
93
|
+
if _, ok := str("title"); !ok {
|
|
94
|
+
return "A session title must be a string"
|
|
95
|
+
}
|
|
96
|
+
case string(ahptypes.ActionTypeChatTurnStarted):
|
|
97
|
+
_, okID := str("turnId")
|
|
98
|
+
_, okAt := str("startedAt")
|
|
99
|
+
if !okID || !okAt {
|
|
100
|
+
return "A turn requires string turnId and startedAt fields"
|
|
101
|
+
}
|
|
102
|
+
if reason := userMessageRejectionReason(plain(raw["message"]), "A client can only start a turn with a user message"); reason != "" {
|
|
103
|
+
return reason
|
|
104
|
+
}
|
|
105
|
+
if _, present := raw["queuedMessageId"]; present {
|
|
106
|
+
return "Only the host can start a queued message"
|
|
107
|
+
}
|
|
108
|
+
if chat != nil && chat.ActiveTurn != nil {
|
|
109
|
+
return "A turn is already active"
|
|
110
|
+
}
|
|
111
|
+
case string(ahptypes.ActionTypeChatTurnCancelled):
|
|
112
|
+
id, okID := str("turnId")
|
|
113
|
+
_, okDuration := raw["duration"].(json.Number)
|
|
114
|
+
if !okID || !okDuration {
|
|
115
|
+
return "Turn cancellation requires a turnId and duration"
|
|
116
|
+
}
|
|
117
|
+
if chat == nil || chat.ActiveTurn == nil || chat.ActiveTurn.Id != id {
|
|
118
|
+
return "No matching active turn to cancel"
|
|
119
|
+
}
|
|
120
|
+
case string(ahptypes.ActionTypeChatPendingMessageSet):
|
|
121
|
+
_, okID := str("id")
|
|
122
|
+
kind, _ := str("kind")
|
|
123
|
+
if !okID || (kind != string(ahptypes.PendingMessageKindSteering) && kind != string(ahptypes.PendingMessageKindQueued)) {
|
|
124
|
+
return "A pending message requires an id and supported kind"
|
|
125
|
+
}
|
|
126
|
+
return userMessageRejectionReason(plain(raw["message"]), "A client can only queue a user message")
|
|
127
|
+
case string(ahptypes.ActionTypeChatPendingMessageRemoved):
|
|
128
|
+
id, okID := str("id")
|
|
129
|
+
if !okID {
|
|
130
|
+
return "A pending message removal requires an id"
|
|
131
|
+
}
|
|
132
|
+
kind, _ := str("kind")
|
|
133
|
+
if kind == string(ahptypes.PendingMessageKindSteering) {
|
|
134
|
+
return "A steering message cannot be withdrawn after pi has queued it"
|
|
135
|
+
}
|
|
136
|
+
if kind != string(ahptypes.PendingMessageKindQueued) {
|
|
137
|
+
return "Unsupported pending message kind"
|
|
138
|
+
}
|
|
139
|
+
if chat != nil {
|
|
140
|
+
for _, m := range chat.QueuedMessages {
|
|
141
|
+
if m.Id == id {
|
|
142
|
+
return ""
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return "No matching queued message to remove"
|
|
147
|
+
case string(ahptypes.ActionTypeChatQueuedMessagesReordered):
|
|
148
|
+
order, ok := raw["order"].([]any)
|
|
149
|
+
if ok {
|
|
150
|
+
for _, id := range order {
|
|
151
|
+
if _, isStr := id.(string); !isStr {
|
|
152
|
+
ok = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if !ok {
|
|
157
|
+
return "Queued message order must be an array of ids"
|
|
158
|
+
}
|
|
159
|
+
case string(ahptypes.ActionTypeChatDraftChanged):
|
|
160
|
+
draft, present := raw["draft"]
|
|
161
|
+
if !present {
|
|
162
|
+
return ""
|
|
163
|
+
}
|
|
164
|
+
return userMessageRejectionReason(plain(draft), "A client can only draft a user message")
|
|
165
|
+
case string(ahptypes.ActionTypeChatTruncated):
|
|
166
|
+
var turnID *string
|
|
167
|
+
if v, present := raw["turnId"]; present {
|
|
168
|
+
s, ok := v.(string)
|
|
169
|
+
if !ok {
|
|
170
|
+
return "A truncation turnId must be a string"
|
|
171
|
+
}
|
|
172
|
+
turnID = &s
|
|
173
|
+
}
|
|
174
|
+
// Accepting an impossible truncation would shorten the client's view while Pi kept using
|
|
175
|
+
// context the user believes is gone.
|
|
176
|
+
owner, ok := r.GetByChat(channel)
|
|
177
|
+
if !ok {
|
|
178
|
+
return ""
|
|
179
|
+
}
|
|
180
|
+
if _, ok := TruncationAnchor(owner, turnID); !ok {
|
|
181
|
+
label := "(all)"
|
|
182
|
+
if turnID != nil {
|
|
183
|
+
label = *turnID
|
|
184
|
+
}
|
|
185
|
+
return fmt.Sprintf("Cannot truncate: no session entry matches turn %s", label)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return ""
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// plain converts json.Number values to float64 so the shared message checks (written over
|
|
192
|
+
// standard decoded JSON) see numbers as they expect.
|
|
193
|
+
func plain(v any) any {
|
|
194
|
+
switch x := v.(type) {
|
|
195
|
+
case map[string]any:
|
|
196
|
+
out := make(map[string]any, len(x))
|
|
197
|
+
for k, e := range x {
|
|
198
|
+
out[k] = plain(e)
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
case []any:
|
|
202
|
+
out := make([]any, len(x))
|
|
203
|
+
for i, e := range x {
|
|
204
|
+
out[i] = plain(e)
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
case json.Number:
|
|
208
|
+
f, _ := x.Float64()
|
|
209
|
+
return f
|
|
210
|
+
}
|
|
211
|
+
return v
|
|
212
|
+
}
|