@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,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { it } from "node:test";
|
|
6
|
+
import { createFauxCore, fauxAssistantMessage, type Message } from "@earendil-works/pi-ai";
|
|
7
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { InProcessPiBackend } from "../src/pi/in-process-backend.ts";
|
|
9
|
+
import { ONE_PIXEL_PNG } from "./support/images.ts";
|
|
10
|
+
import { createReplayEnvironment } from "./support/replay.ts";
|
|
11
|
+
|
|
12
|
+
it("passes and persists images while honouring preflight cancellation", async () => {
|
|
13
|
+
const environment = createReplayEnvironment();
|
|
14
|
+
const workspace = mkdtempSync(join(tmpdir(), "pi-ahp-image-session-"));
|
|
15
|
+
const manager = SessionManager.create(workspace, join(environment.agentDir, "sessions", "image-session"), {
|
|
16
|
+
id: "image-session",
|
|
17
|
+
});
|
|
18
|
+
let backend: InProcessPiBackend | undefined;
|
|
19
|
+
let contextMessages: Message[] = [];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
backend = await InProcessPiBackend.create({ cwd: workspace, sessionManager: manager });
|
|
23
|
+
const faux = createFauxCore({});
|
|
24
|
+
faux.setResponses([
|
|
25
|
+
(context) => {
|
|
26
|
+
contextMessages = structuredClone(context.messages);
|
|
27
|
+
return fauxAssistantMessage("I can see the image.");
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
backend.session.agent.streamFunction = faux.stream as never;
|
|
31
|
+
|
|
32
|
+
await backend.prompt("Describe this image", [{ type: "image", data: ONE_PIXEL_PNG, mimeType: "image/png" }]);
|
|
33
|
+
|
|
34
|
+
const received = contextMessages.findLast((message) => message.role === "user");
|
|
35
|
+
assert.ok(received && Array.isArray(received.content));
|
|
36
|
+
assert.equal(received.content.length, 2);
|
|
37
|
+
assert.deepEqual(received.content[0], { type: "text", text: "Describe this image" });
|
|
38
|
+
const receivedImage = received.content[1];
|
|
39
|
+
assert.equal(receivedImage?.type, "image");
|
|
40
|
+
if (receivedImage?.type === "image") {
|
|
41
|
+
assert.equal(receivedImage.mimeType, "image/png");
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
Buffer.from(receivedImage.data, "base64").subarray(0, 8),
|
|
44
|
+
Buffer.from("89504e470d0a1a0a", "hex"),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const stored = manager.getEntries().find((entry) => entry.type === "message" && entry.message.role === "user");
|
|
49
|
+
const storedMessage = stored?.type === "message" && stored.message.role === "user" ? stored.message : undefined;
|
|
50
|
+
assert.deepEqual(storedMessage?.content, received.content);
|
|
51
|
+
|
|
52
|
+
const cancelled = new AbortController();
|
|
53
|
+
cancelled.abort();
|
|
54
|
+
await backend.prompt(
|
|
55
|
+
"Do not send this",
|
|
56
|
+
[{ type: "image", data: ONE_PIXEL_PNG, mimeType: "image/png" }],
|
|
57
|
+
cancelled.signal,
|
|
58
|
+
);
|
|
59
|
+
assert.equal(faux.state.callCount, 1, "cancelled image preflight started another model request");
|
|
60
|
+
assert.equal(
|
|
61
|
+
manager.getEntries().filter((entry) => entry.type === "message" && entry.message.role === "user").length,
|
|
62
|
+
1,
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
backend?.dispose();
|
|
66
|
+
environment.close();
|
|
67
|
+
rmSync(workspace, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live end-to-end smoke: a real client, a real model, the whole stack.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in — needs a configured pi model provider and network access. Run with:
|
|
5
|
+
*
|
|
6
|
+
* nix develop -c env \
|
|
7
|
+
* PI_AHP_LIVE_SETTINGS=/absolute/path/to/settings.json \
|
|
8
|
+
* PI_AHP_LIVE_AUTH=/absolute/path/to/auth.json \
|
|
9
|
+
* pnpm exec node --test test/live-turn.test.ts
|
|
10
|
+
*
|
|
11
|
+
* The dedicated settings file must explicitly choose a default model and
|
|
12
|
+
* thinking level. Provider packages listed there are installed into the
|
|
13
|
+
* temporary Pi directory when missing; pin their versions for repeatability.
|
|
14
|
+
*
|
|
15
|
+
* Everything else in the suite is deterministic and offline; this exists to
|
|
16
|
+
* catch the one thing scripted backends cannot: that pi's *actual* event
|
|
17
|
+
* stream maps onto the protocol the way the mapper assumes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { after, before, describe, it } from "node:test";
|
|
26
|
+
import { createAgentSessionServices, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import {
|
|
28
|
+
ActionType,
|
|
29
|
+
type ChatState,
|
|
30
|
+
MessageKind,
|
|
31
|
+
type ModelSelection,
|
|
32
|
+
ResponsePartKind,
|
|
33
|
+
type RootState,
|
|
34
|
+
SessionLifecycle,
|
|
35
|
+
type SessionState,
|
|
36
|
+
SUPPORTED_PROTOCOL_VERSIONS,
|
|
37
|
+
ToolCallStatus,
|
|
38
|
+
type Turn,
|
|
39
|
+
TurnState,
|
|
40
|
+
} from "@microsoft/agent-host-protocol";
|
|
41
|
+
import { AhpClient } from "@microsoft/agent-host-protocol/client";
|
|
42
|
+
import { WebSocketTransport } from "@microsoft/agent-host-protocol/ws";
|
|
43
|
+
import { chatUri, sessionUri } from "../src/core/channels.ts";
|
|
44
|
+
import { createPiHost } from "../src/host/pi-host.ts";
|
|
45
|
+
import { InProcessPiBackend } from "../src/pi/in-process-backend.ts";
|
|
46
|
+
import { THINKING_CONFIG_KEY } from "../src/pi/models.ts";
|
|
47
|
+
import { type RunningServer, serveWebSocket } from "../src/transport/websocket.ts";
|
|
48
|
+
import { must, turnError } from "./support/assertions.ts";
|
|
49
|
+
import { eventually } from "./support/async.ts";
|
|
50
|
+
import { assertValid } from "./support/schema.ts";
|
|
51
|
+
import { persistentSessionStorage } from "./support/session-storage.ts";
|
|
52
|
+
|
|
53
|
+
const LIVE_SETTINGS = process.env.PI_AHP_LIVE_SETTINGS?.trim();
|
|
54
|
+
const LIVE_AUTH = process.env.PI_AHP_LIVE_AUTH?.trim();
|
|
55
|
+
const LIVE = LIVE_SETTINGS !== undefined || LIVE_AUTH !== undefined;
|
|
56
|
+
const TURN_TIMEOUT_MS = 180_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Printed by every offline run, so it is the suite's real documentation.
|
|
60
|
+
*
|
|
61
|
+
* `pnpm test` runs the whole directory instead of filtering this file out: a
|
|
62
|
+
* skipped test that says how to unskip itself is discoverable at the moment it
|
|
63
|
+
* is relevant, which a note in a file nobody opens is not.
|
|
64
|
+
*/
|
|
65
|
+
const SKIP_REASON = "needs a real model — set PI_AHP_LIVE_SETTINGS and PI_AHP_LIVE_AUTH";
|
|
66
|
+
|
|
67
|
+
interface LiveSettingsFile {
|
|
68
|
+
readonly defaultProvider?: unknown;
|
|
69
|
+
readonly defaultModel?: unknown;
|
|
70
|
+
readonly defaultThinkingLevel?: unknown;
|
|
71
|
+
readonly modelThinkingLevels?: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("live turn", { skip: LIVE ? false : SKIP_REASON }, () => {
|
|
75
|
+
let root: string;
|
|
76
|
+
let workspace: string;
|
|
77
|
+
let server: RunningServer;
|
|
78
|
+
let client: AhpClient;
|
|
79
|
+
let built: Awaited<ReturnType<typeof createPiHost>>;
|
|
80
|
+
let host: Awaited<ReturnType<typeof createPiHost>>["host"];
|
|
81
|
+
let sessions: Awaited<ReturnType<typeof createPiHost>>["sessions"];
|
|
82
|
+
let selection: ModelSelection;
|
|
83
|
+
let thinking: string;
|
|
84
|
+
let previousAgentDir: string | undefined;
|
|
85
|
+
|
|
86
|
+
before(async () => {
|
|
87
|
+
root = mkdtempSync(join(tmpdir(), "pi-ahp-live-"));
|
|
88
|
+
previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
89
|
+
assert.ok(LIVE_SETTINGS, "PI_AHP_LIVE_SETTINGS must name a dedicated settings.json");
|
|
90
|
+
assert.ok(LIVE_AUTH, "PI_AHP_LIVE_AUTH must name a dedicated auth.json");
|
|
91
|
+
const settingsPath = resolve(LIVE_SETTINGS);
|
|
92
|
+
const authPath = resolve(LIVE_AUTH);
|
|
93
|
+
assert.ok(existsSync(settingsPath), `live-test settings file does not exist: ${settingsPath}`);
|
|
94
|
+
assert.ok(existsSync(authPath), `live-test auth file does not exist: ${authPath}`);
|
|
95
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as LiveSettingsFile;
|
|
96
|
+
assert.ok(
|
|
97
|
+
typeof settings.defaultProvider === "string" && settings.defaultProvider.length > 0,
|
|
98
|
+
"live-test settings must explicitly set defaultProvider",
|
|
99
|
+
);
|
|
100
|
+
assert.ok(
|
|
101
|
+
typeof settings.defaultModel === "string" && settings.defaultModel.length > 0,
|
|
102
|
+
"live-test settings must explicitly set defaultModel",
|
|
103
|
+
);
|
|
104
|
+
const modelId = `${settings.defaultProvider}/${settings.defaultModel}`;
|
|
105
|
+
const perModelThinking = settings.modelThinkingLevels?.[modelId];
|
|
106
|
+
const configuredThinking = perModelThinking ?? settings.defaultThinkingLevel;
|
|
107
|
+
assert.ok(
|
|
108
|
+
typeof configuredThinking === "string" && configuredThinking.length > 0,
|
|
109
|
+
"live-test settings must explicitly set a thinking level",
|
|
110
|
+
);
|
|
111
|
+
thinking = configuredThinking;
|
|
112
|
+
selection = { id: modelId, config: { [THINKING_CONFIG_KEY]: thinking } };
|
|
113
|
+
workspace = join(root, "workspace");
|
|
114
|
+
const agentDir = join(root, "agent");
|
|
115
|
+
const sessionRoot = join(agentDir, "sessions");
|
|
116
|
+
mkdirSync(workspace);
|
|
117
|
+
mkdirSync(agentDir);
|
|
118
|
+
writeFileSync(join(workspace, "note.txt"), "ALPHA BETA GAMMA\n");
|
|
119
|
+
|
|
120
|
+
// These explicit files are the only profile data imported. Package installs,
|
|
121
|
+
// token refreshes, sessions, and any settings writes stay in the temporary
|
|
122
|
+
// profile and disappear with it.
|
|
123
|
+
copyFileSync(settingsPath, join(agentDir, "settings.json"));
|
|
124
|
+
copyFileSync(authPath, join(agentDir, "auth.json"));
|
|
125
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
126
|
+
|
|
127
|
+
// SDK startup is deliberately cache-only. A clean live profile has no
|
|
128
|
+
// models-store yet, so refresh dynamic provider catalogues explicitly;
|
|
129
|
+
// the resulting cache remains inside this temporary directory.
|
|
130
|
+
const discovery = await createAgentSessionServices({
|
|
131
|
+
cwd: workspace,
|
|
132
|
+
settingsManager: SettingsManager.create(workspace, undefined, { projectTrusted: false }),
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual(discovery.diagnostics, [], "live-test provider extension failed to load");
|
|
135
|
+
const refreshed = await discovery.modelRuntime.refresh({
|
|
136
|
+
allowNetwork: true,
|
|
137
|
+
providers: [settings.defaultProvider],
|
|
138
|
+
});
|
|
139
|
+
assert.equal(refreshed.aborted, false, "live-test model discovery was aborted");
|
|
140
|
+
assert.equal(
|
|
141
|
+
refreshed.errors.size,
|
|
142
|
+
0,
|
|
143
|
+
`live-test model discovery failed: ${[...refreshed.errors].map(([provider, error]) => `${provider}: ${error.message}`).join("; ")}`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
built = await createPiHost({
|
|
147
|
+
serverInfo: { name: "pi-ahp", version: "live-test" },
|
|
148
|
+
workingDirectory: workspace,
|
|
149
|
+
modelRuntime: discovery.modelRuntime,
|
|
150
|
+
sessionStorage: persistentSessionStorage(sessionRoot),
|
|
151
|
+
projectTrustPolicy: "never",
|
|
152
|
+
createBackend: async (session) => {
|
|
153
|
+
const backend = await InProcessPiBackend.create({
|
|
154
|
+
cwd: session.workingDirectory,
|
|
155
|
+
sessionManager: session.sessionManager,
|
|
156
|
+
projectTrustPolicy: "never",
|
|
157
|
+
});
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
backend.currentSelection(),
|
|
160
|
+
selection,
|
|
161
|
+
"Pi did not initialize the explicitly requested live-test model",
|
|
162
|
+
);
|
|
163
|
+
return backend;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
host = built.host;
|
|
167
|
+
sessions = built.sessions;
|
|
168
|
+
const rootState = host.store.get("ahp-root://") as RootState;
|
|
169
|
+
assertValid("state", "RootState", rootState);
|
|
170
|
+
const available = rootState.agents.flatMap((agent) => agent.models);
|
|
171
|
+
const requested = available.find((model) => model.id === selection.id);
|
|
172
|
+
assert.ok(
|
|
173
|
+
requested,
|
|
174
|
+
`configured live-test model ${selection.id} is unavailable; available models: ${available.map((model) => model.id).join(", ")}`,
|
|
175
|
+
);
|
|
176
|
+
const configuredLevels = requested.configSchema?.properties?.[THINKING_CONFIG_KEY]?.enum?.filter(
|
|
177
|
+
(level): level is string => typeof level === "string",
|
|
178
|
+
);
|
|
179
|
+
if (configuredLevels && configuredLevels.length > 0) {
|
|
180
|
+
assert.ok(
|
|
181
|
+
configuredLevels.includes(thinking),
|
|
182
|
+
`${selection.id} does not support thinking=${thinking}; choose one of: ${configuredLevels.join(", ")}`,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
assert.equal(thinking, "off", `${selection.id} does not advertise configurable thinking; use off`);
|
|
186
|
+
}
|
|
187
|
+
server = await serveWebSocket(host, { host: "127.0.0.1", port: 0 });
|
|
188
|
+
client = new AhpClient(await WebSocketTransport.connect(`ws://127.0.0.1:${server.port}`));
|
|
189
|
+
client.connect();
|
|
190
|
+
await client.initialize({
|
|
191
|
+
clientId: "live-client",
|
|
192
|
+
protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
|
193
|
+
initialSubscriptions: ["ahp-root://"],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
after(async () => {
|
|
198
|
+
try {
|
|
199
|
+
await client?.shutdown();
|
|
200
|
+
await server?.close();
|
|
201
|
+
built?.terminals.shutdown();
|
|
202
|
+
await Promise.all([built?.changesets.dispose(), built?.watches.dispose()]);
|
|
203
|
+
} finally {
|
|
204
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
205
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
206
|
+
rmSync(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function assertSessionState(sessionChannel: string, chatChannel: string): void {
|
|
211
|
+
assertValid("state", "SessionState", host.store.get(sessionChannel));
|
|
212
|
+
assertValid("state", "ChatState", host.store.get(chatChannel));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertPersistedSelection(sessionChannel: string): void {
|
|
216
|
+
const context = must(sessions.get(sessionChannel)).sessionManager.buildSessionContext();
|
|
217
|
+
const model = must(context.model);
|
|
218
|
+
assert.equal(`${model.provider}/${model.modelId}`, selection.id);
|
|
219
|
+
assert.equal(context.thinkingLevel, thinking);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function runLiveTurn(
|
|
223
|
+
turnId: string,
|
|
224
|
+
text: string,
|
|
225
|
+
): Promise<{ id: string; sessionChannel: string; turn: Turn }> {
|
|
226
|
+
const id = randomUUID();
|
|
227
|
+
const sessionChannel = sessionUri(id);
|
|
228
|
+
const chatChannel = chatUri(id);
|
|
229
|
+
await client.request("createSession", { channel: sessionChannel });
|
|
230
|
+
await client.subscribe(sessionChannel);
|
|
231
|
+
await client.subscribe(chatChannel);
|
|
232
|
+
await eventually(
|
|
233
|
+
"the live session backend to settle",
|
|
234
|
+
() => (host.store.get(sessionChannel) as SessionState).lifecycle !== SessionLifecycle.Creating,
|
|
235
|
+
{ timeoutMs: 30_000, intervalMs: 50 },
|
|
236
|
+
);
|
|
237
|
+
const sessionState = host.store.get(sessionChannel) as SessionState;
|
|
238
|
+
assert.equal(sessionState.lifecycle, SessionLifecycle.Ready, sessionState.creationError?.message);
|
|
239
|
+
client.dispatch(chatChannel, {
|
|
240
|
+
type: ActionType.ChatTurnStarted,
|
|
241
|
+
turnId,
|
|
242
|
+
startedAt: new Date().toISOString(),
|
|
243
|
+
message: { text, origin: { kind: MessageKind.User }, model: selection },
|
|
244
|
+
});
|
|
245
|
+
await eventually("the live turn to complete", () => (host.store.get(chatChannel) as ChatState).turns.length === 1, {
|
|
246
|
+
timeoutMs: TURN_TIMEOUT_MS,
|
|
247
|
+
intervalMs: 50,
|
|
248
|
+
});
|
|
249
|
+
const state = host.store.get(chatChannel) as ChatState;
|
|
250
|
+
assertSessionState(sessionChannel, chatChannel);
|
|
251
|
+
const turn = must(state.turns[0]);
|
|
252
|
+
assert.equal(turn.state, TurnState.Complete, `turn failed: ${turnError(turn)?.message ?? ""}`);
|
|
253
|
+
assert.equal(state.activeTurn, undefined);
|
|
254
|
+
assertPersistedSelection(sessionChannel);
|
|
255
|
+
return { id, sessionChannel, turn };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
it("streams a real assistant reply into chat state", async () => {
|
|
259
|
+
const { id, sessionChannel, turn } = await runLiveTurn("live-1", "Reply with exactly the word: PONG");
|
|
260
|
+
|
|
261
|
+
const text = turn.responseParts
|
|
262
|
+
.filter((part) => part.kind === ResponsePartKind.Markdown)
|
|
263
|
+
.map((part) => (part as { content: string }).content)
|
|
264
|
+
.join("");
|
|
265
|
+
assert.match(text, /PONG/i);
|
|
266
|
+
// Usage only arrives if `message_end` carried it, which is the path the
|
|
267
|
+
// mapper reads.
|
|
268
|
+
assert.ok(turn.usage?.outputTokens !== undefined, "no usage reported");
|
|
269
|
+
|
|
270
|
+
const live = sessions.get(sessionChannel);
|
|
271
|
+
assert.ok(live, "session should still be live");
|
|
272
|
+
assert.equal(live.sessionId, id);
|
|
273
|
+
const file = live.sessionManager.getSessionFile();
|
|
274
|
+
assert.ok(file, "pi should have written a session file for a real turn");
|
|
275
|
+
const contents = readFileSync(file, "utf8");
|
|
276
|
+
assert.match(contents, /Reply with exactly the word: PONG/);
|
|
277
|
+
assert.match(contents, new RegExp(`"id"\\s*:\\s*"${id}"`));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("drives a real tool call to completion without confirmation", async () => {
|
|
281
|
+
const { turn } = await runLiveTurn(
|
|
282
|
+
"live-tool",
|
|
283
|
+
"Read note.txt in the working directory and reply with its exact contents.",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const toolCalls = turn.responseParts.filter((part) => part.kind === ResponsePartKind.ToolCall);
|
|
287
|
+
assert.ok(toolCalls.length > 0, "expected at least one tool call");
|
|
288
|
+
for (const part of toolCalls) {
|
|
289
|
+
const toolCall = (part as { toolCall: { status: string; toolName: string } }).toolCall;
|
|
290
|
+
// Auto-confirmed calls go straight to running, so every one should
|
|
291
|
+
// have reached a terminal state by the time the turn closed.
|
|
292
|
+
assert.ok(
|
|
293
|
+
toolCall.status === ToolCallStatus.Completed || toolCall.status === ToolCallStatus.Cancelled,
|
|
294
|
+
`tool ${toolCall.toolName} ended in ${toolCall.status}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const text = turn.responseParts
|
|
299
|
+
.filter((part) => part.kind === ResponsePartKind.Markdown)
|
|
300
|
+
.map((part) => (part as { content: string }).content)
|
|
301
|
+
.join("");
|
|
302
|
+
assert.match(text, /ALPHA BETA GAMMA/);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replays recorded pi event streams through the mapper, offline.
|
|
3
|
+
*
|
|
4
|
+
* These fixtures were captured from a real model (`scripts/capture-fixtures.ts`)
|
|
5
|
+
* and scrubbed of machine- and tenant-specific values. They cover provider
|
|
6
|
+
* event shapes that synthetic mapper fixtures may omit.
|
|
7
|
+
*
|
|
8
|
+
* The assertions are deliberately structural rather than exact-output: the model
|
|
9
|
+
* is free to phrase things differently on a re-capture, but the *shape* of the
|
|
10
|
+
* turn must hold.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import {
|
|
16
|
+
ActionType,
|
|
17
|
+
type ChatState,
|
|
18
|
+
chatReducer,
|
|
19
|
+
ResponsePartKind,
|
|
20
|
+
type StateAction,
|
|
21
|
+
ToolCallStatus,
|
|
22
|
+
TurnState,
|
|
23
|
+
} from "@microsoft/agent-host-protocol";
|
|
24
|
+
import { initialChatState } from "../src/channels/chat.ts";
|
|
25
|
+
import { TurnMapper, userTurnStarted } from "../src/pi/event-mapper.ts";
|
|
26
|
+
import { must } from "./support/assertions.ts";
|
|
27
|
+
import { loadRecordedFixtures, type RecordedFixture } from "./support/recorded-fixtures.ts";
|
|
28
|
+
import { assertValid } from "./support/schema.ts";
|
|
29
|
+
|
|
30
|
+
const CHAT_URI = "ahp-chat:/replay";
|
|
31
|
+
const TURN_ID = "replay-turn";
|
|
32
|
+
|
|
33
|
+
interface Replayed {
|
|
34
|
+
readonly fixture: RecordedFixture;
|
|
35
|
+
readonly actions: StateAction[];
|
|
36
|
+
readonly state: ChatState;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replay(fixture: RecordedFixture): Replayed {
|
|
40
|
+
const mapper = new TurnMapper(TURN_ID, 0);
|
|
41
|
+
const actions: StateAction[] = [userTurnStarted(TURN_ID, fixture.prompt, "1970-01-01T00:00:00.000Z")];
|
|
42
|
+
for (const event of fixture.events) {
|
|
43
|
+
actions.push(...mapper.handle(event));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let state = initialChatState(CHAT_URI, "Replay");
|
|
47
|
+
for (const action of actions) {
|
|
48
|
+
state = chatReducer(state, action as never);
|
|
49
|
+
}
|
|
50
|
+
return { fixture, actions, state };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fixtures = loadRecordedFixtures().map(({ fixture }) => fixture);
|
|
54
|
+
const fixturesByName = new Map(fixtures.map((fixture) => [fixture.name, fixture]));
|
|
55
|
+
const replayedByName = new Map<string, Replayed>();
|
|
56
|
+
|
|
57
|
+
function get(name: string): Replayed {
|
|
58
|
+
const fixture = fixturesByName.get(name);
|
|
59
|
+
assert.ok(fixture, `unknown recorded fixture: ${name}`);
|
|
60
|
+
let replayed = replayedByName.get(name);
|
|
61
|
+
if (!replayed) {
|
|
62
|
+
replayed = replay(fixture);
|
|
63
|
+
replayedByName.set(name, replayed);
|
|
64
|
+
}
|
|
65
|
+
return replayed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function allReplayed(): Replayed[] {
|
|
69
|
+
return fixtures.map((fixture) => get(fixture.name));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function markdownText(state: ChatState): string {
|
|
73
|
+
const turn = state.turns[0] ?? state.activeTurn;
|
|
74
|
+
return (turn?.responseParts ?? [])
|
|
75
|
+
.filter((part) => part.kind === ResponsePartKind.Markdown)
|
|
76
|
+
.map((part) => (part as { content: string }).content)
|
|
77
|
+
.join("");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ReplayedToolCall {
|
|
81
|
+
readonly status: string;
|
|
82
|
+
readonly toolName: string;
|
|
83
|
+
readonly success?: boolean;
|
|
84
|
+
readonly displayName?: string;
|
|
85
|
+
readonly invocationMessage?: string;
|
|
86
|
+
readonly pastTenseMessage?: string;
|
|
87
|
+
readonly toolInput?: string;
|
|
88
|
+
readonly content?: readonly { readonly type: string; readonly text?: string }[];
|
|
89
|
+
readonly error?: { readonly message?: string };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toolCalls(state: ChatState): ReplayedToolCall[] {
|
|
93
|
+
const turn = state.turns[0] ?? state.activeTurn;
|
|
94
|
+
return (turn?.responseParts ?? [])
|
|
95
|
+
.filter((part) => part.kind === ResponsePartKind.ToolCall)
|
|
96
|
+
.map((part) => (part as { toolCall: ReplayedToolCall }).toolCall);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The text a tool reported, as the client would concatenate it. */
|
|
100
|
+
function toolResultText(call: ReplayedToolCall): string {
|
|
101
|
+
return (call.content ?? [])
|
|
102
|
+
.filter((block) => block.type === "text")
|
|
103
|
+
.map((block) => block.text ?? "")
|
|
104
|
+
.join("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertReplayInvariants({ fixture, actions, state }: Replayed): void {
|
|
108
|
+
const opened = actions.filter((action) => action.type === ActionType.ChatTurnStarted).length;
|
|
109
|
+
const terminators = actions.filter(
|
|
110
|
+
(action) =>
|
|
111
|
+
action.type === ActionType.ChatTurnComplete ||
|
|
112
|
+
action.type === ActionType.ChatTurnCancelled ||
|
|
113
|
+
action.type === ActionType.ChatError,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// More than one turn means pi injected a message mid-run; each still has to
|
|
117
|
+
// terminate exactly once.
|
|
118
|
+
assert.equal(terminators.length, opened, `${fixture.name}: every opened turn must terminate once`);
|
|
119
|
+
assert.equal(state.activeTurn, undefined, `${fixture.name}: active turn remained after replay`);
|
|
120
|
+
assert.equal(state.turns.length, opened, `${fixture.name}: reduced turn count differs from opened turns`);
|
|
121
|
+
|
|
122
|
+
for (const action of actions) {
|
|
123
|
+
assertValid("actions", "StateAction", action, `${fixture.name}: non-conforming ${action.type}`);
|
|
124
|
+
}
|
|
125
|
+
for (const call of toolCalls(state)) {
|
|
126
|
+
assert.ok(
|
|
127
|
+
call.status === ToolCallStatus.Completed || call.status === ToolCallStatus.Cancelled,
|
|
128
|
+
`${fixture.name}: ${call.toolName} ended in ${call.status}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A delta naming an unknown partId is a silent reducer no-op, so validate
|
|
133
|
+
// creation order as well as uniqueness.
|
|
134
|
+
const created = new Set<string>();
|
|
135
|
+
const ids: Array<string | undefined> = [];
|
|
136
|
+
for (const action of actions) {
|
|
137
|
+
if (action.type === ActionType.ChatResponsePart) {
|
|
138
|
+
const id = "id" in action.part ? action.part.id : undefined;
|
|
139
|
+
ids.push(id);
|
|
140
|
+
if (id) created.add(id);
|
|
141
|
+
}
|
|
142
|
+
if (action.type === ActionType.ChatDelta || action.type === ActionType.ChatReasoning) {
|
|
143
|
+
assert.ok(created.has(action.partId), `${fixture.name}: delta targets unknown part ${action.partId}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
assert.equal(new Set(ids).size, ids.length, `${fixture.name}: response part ids collided within a turn`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("recorded stream replay — invariants", () => {
|
|
150
|
+
for (const fixture of fixtures) {
|
|
151
|
+
it(`${fixture.name} — ${fixture.description}`, () => {
|
|
152
|
+
assertReplayInvariants(get(fixture.name));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("recorded stream replay — per scenario", () => {
|
|
158
|
+
it("plain-text: answers with no tool calls", () => {
|
|
159
|
+
const { state } = get("plain-text");
|
|
160
|
+
assert.equal(state.turns[0]?.state, TurnState.Complete);
|
|
161
|
+
assert.equal(toolCalls(state).length, 0);
|
|
162
|
+
assert.match(markdownText(state), /PONG/i);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("describes a tool call in terms a user can act on", () => {
|
|
166
|
+
// These three fields are what a client renders around a tool call: a
|
|
167
|
+
// heading, a line while it runs, and a line once it has. pi knows which
|
|
168
|
+
// file was read or which command ran, and a message that only repeats the
|
|
169
|
+
// tool's name spends all three on saying `read` three times.
|
|
170
|
+
for (const { fixture, state } of allReplayed()) {
|
|
171
|
+
for (const call of toolCalls(state)) {
|
|
172
|
+
if (!call.toolInput || call.toolInput === "{}") {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
assert.notEqual(
|
|
176
|
+
call.invocationMessage,
|
|
177
|
+
call.toolName,
|
|
178
|
+
`${fixture.name}: ${call.toolName} announces itself with nothing but its own name`,
|
|
179
|
+
);
|
|
180
|
+
// The two lines sit next to each other in a transcript, one for a
|
|
181
|
+
// call in flight and one for a call that finished. Identical text
|
|
182
|
+
// leaves a completed call still claiming to be running.
|
|
183
|
+
assert.notEqual(
|
|
184
|
+
call.pastTenseMessage,
|
|
185
|
+
call.invocationMessage,
|
|
186
|
+
`${fixture.name}: ${call.toolName} still says it is ${call.invocationMessage} after it finished`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("shows a tool's subject rather than its arguments as JSON", () => {
|
|
193
|
+
// `toolInput` is what a client renders for the call itself, and the
|
|
194
|
+
// protocol carries no structured parameters beside it, so serialising the
|
|
195
|
+
// argument object spends the field on quoting and braces. Each of pi's
|
|
196
|
+
// tools has one argument that says what the call is about.
|
|
197
|
+
for (const { fixture, state } of allReplayed()) {
|
|
198
|
+
for (const call of toolCalls(state)) {
|
|
199
|
+
if (call.toolInput === undefined) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
assert.ok(
|
|
203
|
+
!call.toolInput.startsWith("{"),
|
|
204
|
+
`${fixture.name}: ${call.toolName} shows its arguments as JSON: ${call.toolInput.slice(0, 60)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("tool-edit: hands the client the patch, not a count of edited blocks", () => {
|
|
211
|
+
// pi computes a unified diff for every edit and reports only how many
|
|
212
|
+
// blocks it replaced. The patch is the part a client can render — the
|
|
213
|
+
// same monospace, syntax-highlighted block a shell command gets.
|
|
214
|
+
const { state } = get("tool-edit");
|
|
215
|
+
const edits = toolCalls(state).filter((call) => call.toolName === "edit");
|
|
216
|
+
assert.ok(edits.length >= 1, "expected an edit call");
|
|
217
|
+
for (const call of edits) {
|
|
218
|
+
const shown = toolResultText(call);
|
|
219
|
+
assert.match(shown, /^--- |\n--- /, `edit result carries no patch: ${shown.slice(0, 80)}`);
|
|
220
|
+
assert.match(shown, /^\+.*$/m, "a patch with no added lines is not a patch");
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("single-tool: runs one tool and answers from its result", () => {
|
|
225
|
+
const { state } = get("single-tool");
|
|
226
|
+
assert.equal(state.turns[0]?.state, TurnState.Complete);
|
|
227
|
+
assert.ok(toolCalls(state).length >= 1);
|
|
228
|
+
assert.match(markdownText(state), /ALPHA BETA GAMMA/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("parallel-tools: keeps several tool calls in one message distinct", () => {
|
|
232
|
+
const { state } = get("parallel-tools");
|
|
233
|
+
const calls = toolCalls(state);
|
|
234
|
+
assert.ok(calls.length >= 2, `expected multiple tool calls, got ${calls.length}`);
|
|
235
|
+
assert.match(markdownText(state), /FIRST/);
|
|
236
|
+
assert.match(markdownText(state), /SECOND/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("tool-loop: spans several assistant messages without part collisions", () => {
|
|
240
|
+
const { fixture, state } = get("tool-loop");
|
|
241
|
+
const assistantMessages = fixture.events.filter(
|
|
242
|
+
(event) =>
|
|
243
|
+
event.type === "message_start" && (event as { message?: { role?: string } }).message?.role === "assistant",
|
|
244
|
+
).length;
|
|
245
|
+
|
|
246
|
+
// The scenario exists to produce more than one assistant message, which
|
|
247
|
+
// is where pi's contentIndex restarts at 0.
|
|
248
|
+
assert.ok(assistantMessages >= 2, `expected multiple assistant messages, got ${assistantMessages}`);
|
|
249
|
+
assert.equal(state.turns[0]?.state, TurnState.Complete);
|
|
250
|
+
assert.match(markdownText(state), /42/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("tool-error: surfaces a failed tool without failing the turn", () => {
|
|
254
|
+
const { state } = get("tool-error");
|
|
255
|
+
const failed = toolCalls(state).filter((call) => call.success === false);
|
|
256
|
+
assert.ok(failed.length >= 1, "expected a failed tool call");
|
|
257
|
+
|
|
258
|
+
// What the client shows for a failure is `error.message`. pi says why —
|
|
259
|
+
// here an ENOENT naming the missing path — and a message that only
|
|
260
|
+
// repeats the tool's name tells the user nothing they did not know.
|
|
261
|
+
for (const call of failed) {
|
|
262
|
+
const reported = toolResultText(call);
|
|
263
|
+
assert.ok(reported.length > 0, `${call.toolName}: no failure text to show`);
|
|
264
|
+
assert.equal(
|
|
265
|
+
call.error?.message,
|
|
266
|
+
reported,
|
|
267
|
+
`${call.toolName}: error.message should carry what pi reported, not a restatement of the tool name`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
// The agent recovers and answers, so the turn itself still completes.
|
|
271
|
+
assert.equal(state.turns[0]?.state, TurnState.Complete);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("abort: ends the turn as cancelled", () => {
|
|
275
|
+
const { state } = get("abort");
|
|
276
|
+
assert.equal(state.turns[0]?.state, TurnState.Cancelled);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("steering: the injected message becomes its own turn", () => {
|
|
280
|
+
const { fixture, state } = get("steering");
|
|
281
|
+
const injectedUserMessages = fixture.events.filter(
|
|
282
|
+
(event) => event.type === "message_start" && (event as { message?: { role?: string } }).message?.role === "user",
|
|
283
|
+
).length;
|
|
284
|
+
assert.ok(injectedUserMessages >= 2, "expected the steering message to appear mid-run");
|
|
285
|
+
|
|
286
|
+
// pi stores an injected message as an ordinary user message, with
|
|
287
|
+
// nothing marking it as steering — so a rebuild from disk necessarily
|
|
288
|
+
// makes it a turn. The live path matches, or the same conversation would
|
|
289
|
+
// render differently before and after a reload.
|
|
290
|
+
assert.equal(state.turns.length, injectedUserMessages);
|
|
291
|
+
assert.match(must(state.turns.at(-1)).message.text, /Stop counting/);
|
|
292
|
+
});
|
|
293
|
+
});
|