@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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving the `devtunnel` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Not bundled: it is a separate .NET CLI that only tunnel users need, and it
|
|
5
|
+
* must be installed and logged in before these operations can run.
|
|
6
|
+
*
|
|
7
|
+
* The CLI's JSON is decoded at this boundary. Decoders accept additive fields
|
|
8
|
+
* but reject missing or malformed fields that the host relies on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { IDENTITY_LABEL, LAUNCHER_LABEL, PROTOCOL_LABEL, TUNNEL_PORT } from "./discovery.ts";
|
|
13
|
+
|
|
14
|
+
export class TunnelError extends Error {}
|
|
15
|
+
|
|
16
|
+
export interface DevTunnelCommandResult {
|
|
17
|
+
readonly status: number | null;
|
|
18
|
+
readonly signal: NodeJS.Signals | null;
|
|
19
|
+
readonly stdout: string;
|
|
20
|
+
readonly stderr: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DevTunnelRunner = (args: readonly string[]) => DevTunnelCommandResult;
|
|
24
|
+
|
|
25
|
+
interface PortDetails {
|
|
26
|
+
readonly portNumber: number;
|
|
27
|
+
readonly protocol: string;
|
|
28
|
+
readonly accessControl?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Tunnel {
|
|
32
|
+
readonly tunnelId: string;
|
|
33
|
+
readonly labels: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Process boundary ----------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function missingCliError(): TunnelError {
|
|
39
|
+
return new TunnelError(
|
|
40
|
+
[
|
|
41
|
+
"`devtunnel` not found on PATH.",
|
|
42
|
+
"",
|
|
43
|
+
"Install it from https://aka.ms/devtunnels/download, then log in:",
|
|
44
|
+
"",
|
|
45
|
+
" devtunnel user login -g GitHub account",
|
|
46
|
+
" devtunnel user login Microsoft account (the default)",
|
|
47
|
+
].join("\n"),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function spawnDevtunnel(args: readonly string[]): DevTunnelCommandResult {
|
|
52
|
+
const result = spawnSync("devtunnel", [...args], { encoding: "utf8" });
|
|
53
|
+
if (result.error) {
|
|
54
|
+
const error = result.error as NodeJS.ErrnoException;
|
|
55
|
+
if (error.code === "ENOENT") {
|
|
56
|
+
throw missingCliError();
|
|
57
|
+
}
|
|
58
|
+
throw new TunnelError(`failed to start devtunnel:\n${error.message}`);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
status: result.status,
|
|
62
|
+
signal: result.signal,
|
|
63
|
+
stdout: result.stdout ?? "",
|
|
64
|
+
stderr: result.stderr ?? "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function failureDetail(result: DevTunnelCommandResult): string {
|
|
69
|
+
const termination = result.signal
|
|
70
|
+
? `signal ${result.signal}`
|
|
71
|
+
: result.status === null
|
|
72
|
+
? "no exit status"
|
|
73
|
+
: `exit status ${result.status}`;
|
|
74
|
+
const output = [result.stderr.trim(), result.stdout.trim()].filter(Boolean);
|
|
75
|
+
return [termination, ...output].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function payload(stdout: string): string {
|
|
79
|
+
return stdout.trim() || "<empty output>";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseObject(command: string, stdout: string): Record<string, unknown> {
|
|
83
|
+
let value: unknown;
|
|
84
|
+
try {
|
|
85
|
+
value = JSON.parse(stdout);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new TunnelError(`devtunnel ${command} returned invalid JSON:\n${payload(stdout)}`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
90
|
+
throw new TunnelError(`devtunnel ${command} returned an unexpected JSON value:\n${payload(stdout)}`);
|
|
91
|
+
}
|
|
92
|
+
return value as Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Command-specific JSON decoders -------------------------------------------
|
|
96
|
+
|
|
97
|
+
function unexpectedResponse(command: string, stdout: string): TunnelError {
|
|
98
|
+
return new TunnelError(`devtunnel ${command} returned an unexpected response:\n${payload(stdout)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function decodeLoginStatus(value: Record<string, unknown>, stdout: string): "logged-in" | "logged-out" {
|
|
102
|
+
if (typeof value.status !== "string") {
|
|
103
|
+
throw unexpectedResponse("user show", stdout);
|
|
104
|
+
}
|
|
105
|
+
switch (value.status.trim().toLowerCase()) {
|
|
106
|
+
case "logged in":
|
|
107
|
+
return "logged-in";
|
|
108
|
+
case "not logged in":
|
|
109
|
+
return "logged-out";
|
|
110
|
+
default:
|
|
111
|
+
throw unexpectedResponse("user show", stdout);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decodeTunnels(value: Record<string, unknown>, stdout: string): Tunnel[] {
|
|
116
|
+
if (Array.isArray(value.tunnels)) {
|
|
117
|
+
return value.tunnels.map((item) => {
|
|
118
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
119
|
+
throw unexpectedResponse("list", stdout);
|
|
120
|
+
}
|
|
121
|
+
const tunnel = item as Record<string, unknown>;
|
|
122
|
+
if (
|
|
123
|
+
typeof tunnel.tunnelId !== "string" ||
|
|
124
|
+
tunnel.tunnelId.length === 0 ||
|
|
125
|
+
!Array.isArray(tunnel.labels) ||
|
|
126
|
+
!tunnel.labels.every((label) => typeof label === "string")
|
|
127
|
+
) {
|
|
128
|
+
throw unexpectedResponse("list", stdout);
|
|
129
|
+
}
|
|
130
|
+
return { tunnelId: tunnel.tunnelId, labels: tunnel.labels };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (typeof value.warning === "string" && /\bno tunnels? found\b/i.test(value.warning)) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
throw unexpectedResponse("list", stdout);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function decodeCreatedTunnelId(value: Record<string, unknown>, stdout: string): string {
|
|
140
|
+
if (typeof value.tunnel !== "object" || value.tunnel === null || Array.isArray(value.tunnel)) {
|
|
141
|
+
throw unexpectedResponse("create", stdout);
|
|
142
|
+
}
|
|
143
|
+
const id = (value.tunnel as Record<string, unknown>).tunnelId;
|
|
144
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
145
|
+
throw unexpectedResponse("create", stdout);
|
|
146
|
+
}
|
|
147
|
+
return id;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function decodePort(value: unknown, command: "port list" | "port show", stdout: string): PortDetails {
|
|
151
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
152
|
+
throw unexpectedResponse(command, stdout);
|
|
153
|
+
}
|
|
154
|
+
const port = value as Record<string, unknown>;
|
|
155
|
+
const protocol = typeof port.protocol === "string" ? port.protocol.trim().toLowerCase() : "";
|
|
156
|
+
if (
|
|
157
|
+
typeof port.portNumber !== "number" ||
|
|
158
|
+
!Number.isSafeInteger(port.portNumber) ||
|
|
159
|
+
port.portNumber < 1 ||
|
|
160
|
+
port.portNumber > 65_535 ||
|
|
161
|
+
protocol.length === 0
|
|
162
|
+
) {
|
|
163
|
+
throw unexpectedResponse(command, stdout);
|
|
164
|
+
}
|
|
165
|
+
return { portNumber: port.portNumber, protocol, accessControl: port.accessControl };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function decodePortList(value: Record<string, unknown>, stdout: string): PortDetails[] {
|
|
169
|
+
if (Array.isArray(value.ports)) {
|
|
170
|
+
return value.ports.map((port) => decodePort(port, "port list", stdout));
|
|
171
|
+
}
|
|
172
|
+
if (typeof value.warning === "string" && /\bno (?:tunnel )?ports? found\b/i.test(value.warning)) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
throw unexpectedResponse("port list", stdout);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function decodePortDetails(value: Record<string, unknown>, stdout: string): PortDetails {
|
|
179
|
+
return decodePort(value.port, "port show", stdout);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Domain adapter ------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export class DevTunnelCli {
|
|
185
|
+
readonly #runner: DevTunnelRunner;
|
|
186
|
+
|
|
187
|
+
constructor(runner: DevTunnelRunner = spawnDevtunnel) {
|
|
188
|
+
this.#runner = runner;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#run(command: string, args: readonly string[]): DevTunnelCommandResult {
|
|
192
|
+
const result = this.#runner(args);
|
|
193
|
+
if (result.status !== 0) {
|
|
194
|
+
throw new TunnelError(`devtunnel ${command} failed:\n${failureDetail(result)}`);
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#runJson(command: string, args: readonly string[]): { value: Record<string, unknown>; stdout: string } {
|
|
200
|
+
const result = this.#run(command, args);
|
|
201
|
+
return { value: parseObject(command, result.stdout), stdout: result.stdout };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
requireLogin(): void {
|
|
205
|
+
const { value, stdout } = this.#runJson("user show", ["user", "show", "--json"]);
|
|
206
|
+
if (decodeLoginStatus(value, stdout) === "logged-out") {
|
|
207
|
+
throw new TunnelError(
|
|
208
|
+
[
|
|
209
|
+
"`devtunnel` is not logged in. Run one of:",
|
|
210
|
+
"",
|
|
211
|
+
" devtunnel user login -g GitHub account",
|
|
212
|
+
" devtunnel user login Microsoft account (the default)",
|
|
213
|
+
"",
|
|
214
|
+
"Add -d for device-code auth when there is no browser.",
|
|
215
|
+
].join("\n"),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Returns this host's first labelled tunnel, if a previous run made one. */
|
|
221
|
+
findTunnel(): Tunnel | undefined {
|
|
222
|
+
const { value, stdout } = this.#runJson("list", [
|
|
223
|
+
"list",
|
|
224
|
+
"--all-labels",
|
|
225
|
+
IDENTITY_LABEL,
|
|
226
|
+
"--all-labels",
|
|
227
|
+
LAUNCHER_LABEL,
|
|
228
|
+
"--json",
|
|
229
|
+
]);
|
|
230
|
+
const found = decodeTunnels(value, stdout)[0];
|
|
231
|
+
if (found && (!found.labels.includes(IDENTITY_LABEL) || !found.labels.includes(LAUNCHER_LABEL))) {
|
|
232
|
+
throw unexpectedResponse("list", stdout);
|
|
233
|
+
}
|
|
234
|
+
return found;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
createTunnel(name: string | undefined): string {
|
|
238
|
+
const labels = [LAUNCHER_LABEL, PROTOCOL_LABEL, IDENTITY_LABEL, ...(name ? [name] : [])];
|
|
239
|
+
const { value, stdout } = this.#runJson("create", [
|
|
240
|
+
"create",
|
|
241
|
+
"--json",
|
|
242
|
+
...labels.flatMap((label) => ["-l", label]),
|
|
243
|
+
]);
|
|
244
|
+
return decodeCreatedTunnelId(value, stdout);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#createPort(id: string): void {
|
|
248
|
+
this.#run("port create", ["port", "create", id, "-p", String(TUNNEL_PORT), "--protocol", "http", "--json"]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Ensures the fixed port uses an HTTP origin. The relay terminates TLS, while
|
|
253
|
+
* pi-ahp's local listener speaks HTTP/WebSocket. A wrong-protocol port is
|
|
254
|
+
* replaced only after confirming that it has no port-specific ACL.
|
|
255
|
+
*/
|
|
256
|
+
ensurePort(id: string): void {
|
|
257
|
+
const listed = this.#runJson("port list", ["port", "list", id, "--json"]);
|
|
258
|
+
const current = decodePortList(listed.value, listed.stdout).find((port) => port.portNumber === TUNNEL_PORT);
|
|
259
|
+
if (!current) {
|
|
260
|
+
this.#createPort(id);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (current.protocol === "http") return;
|
|
264
|
+
|
|
265
|
+
// List output does not carry port-specific ACLs. Re-read the details before
|
|
266
|
+
// a destructive protocol migration, and trust the newer response if the
|
|
267
|
+
// port changed concurrently.
|
|
268
|
+
const shown = this.#runJson("port show", ["port", "show", id, "-p", String(TUNNEL_PORT), "--json"]);
|
|
269
|
+
const details = decodePortDetails(shown.value, shown.stdout);
|
|
270
|
+
if (details.portNumber !== TUNNEL_PORT) {
|
|
271
|
+
throw unexpectedResponse("port show", shown.stdout);
|
|
272
|
+
}
|
|
273
|
+
if (details.protocol === "http") return;
|
|
274
|
+
const hasPortAcl = Array.isArray(details.accessControl)
|
|
275
|
+
? details.accessControl.length > 0
|
|
276
|
+
: details.accessControl != null;
|
|
277
|
+
if (hasPortAcl) {
|
|
278
|
+
throw new TunnelError(
|
|
279
|
+
`devtunnel port ${TUNNEL_PORT} uses ${details.protocol} and has port-specific access control; replace it manually`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.#run("port delete", ["port", "delete", id, "-p", String(TUNNEL_PORT), "--json"]);
|
|
284
|
+
this.#createPort(id);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Swaps the display label, leaving the identity and protocol labels alone. */
|
|
288
|
+
rename(id: string, from: string | undefined, to: string): void {
|
|
289
|
+
const args = ["update", id, "--add-labels", to];
|
|
290
|
+
if (from) args.push("--remove-labels", from);
|
|
291
|
+
this.#run("update", args);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Shared Agent Host Dev Tunnel discovery contract plus pi-ahp's private labels. */
|
|
2
|
+
|
|
3
|
+
/** Required for clients using the VS Code-compatible discovery convention. */
|
|
4
|
+
export const LAUNCHER_LABEL = "vscode-server-launcher";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Protocol 5 selects direct forwarding. Protocol 6 expects an
|
|
8
|
+
* `/agent-host/select` gateway, which a plain `devtunnel` port forward cannot
|
|
9
|
+
* provide. VS Code appends a tunnel-id-derived `tkn` on the v5 path; pi-ahp
|
|
10
|
+
* ignores it and relies on the Dev Tunnel access boundary.
|
|
11
|
+
*
|
|
12
|
+
* This versions the tunnel arrangement, not AHP itself.
|
|
13
|
+
*/
|
|
14
|
+
export const PROTOCOL_LABEL = "protocolv5";
|
|
15
|
+
|
|
16
|
+
/** The fixed forwarded port used by the discovery convention. */
|
|
17
|
+
export const TUNNEL_PORT = 31546;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Finds this host's tunnel on later runs without becoming its display name.
|
|
21
|
+
* Compatible clients exclude underscore-prefixed labels from that name.
|
|
22
|
+
*/
|
|
23
|
+
export const IDENTITY_LABEL = "_pi_ahp";
|
|
24
|
+
|
|
25
|
+
/** Returns the client-facing tunnel name, when one exists. */
|
|
26
|
+
export function displayLabel(labels: readonly string[]): string | undefined {
|
|
27
|
+
return labels.find((label) => !label.startsWith("_") && label !== LAUNCHER_LABEL && !label.startsWith("protocolv"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Applies the discovery convention's normalization to a user-supplied name. */
|
|
31
|
+
export function nameLabel(name: string): string | undefined {
|
|
32
|
+
const slug = name
|
|
33
|
+
.replace(/^-+/g, "")
|
|
34
|
+
.replace(/[^\w-]/g, "")
|
|
35
|
+
.substring(0, 20);
|
|
36
|
+
return slug || undefined;
|
|
37
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { describe, it } from "node:test";
|
|
7
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
type ActionEnvelope,
|
|
10
|
+
ActionType,
|
|
11
|
+
type ChatState,
|
|
12
|
+
MessageKind,
|
|
13
|
+
PendingMessageKind,
|
|
14
|
+
type ReconnectReplayResult,
|
|
15
|
+
ReconnectResultType,
|
|
16
|
+
type ReconnectSnapshotResult,
|
|
17
|
+
ResponsePartKind,
|
|
18
|
+
SUPPORTED_PROTOCOL_VERSIONS,
|
|
19
|
+
TurnState,
|
|
20
|
+
} from "@microsoft/agent-host-protocol";
|
|
21
|
+
import type { AhpClient, Subscription } from "@microsoft/agent-host-protocol/client";
|
|
22
|
+
import { chatUri, ROOT_CHANNEL, sessionUri } from "../src/core/channels.ts";
|
|
23
|
+
import type { PiBackend } from "../src/pi/chat-driver.ts";
|
|
24
|
+
import { type Harness, nextClientId, startHarness } from "./harness.ts";
|
|
25
|
+
import { eventually } from "./support/async.ts";
|
|
26
|
+
|
|
27
|
+
const CLIENT_ID = "active-turn-reconnect-client";
|
|
28
|
+
const TURN_ID = "active-turn";
|
|
29
|
+
|
|
30
|
+
class ControlledBackend implements PiBackend {
|
|
31
|
+
readonly prompts: string[] = [];
|
|
32
|
+
readonly steers: string[] = [];
|
|
33
|
+
aborts = 0;
|
|
34
|
+
readonly #listeners = new Set<(event: AgentSessionEvent) => void>();
|
|
35
|
+
|
|
36
|
+
subscribe(listener: (event: AgentSessionEvent) => void): () => void {
|
|
37
|
+
this.#listeners.add(listener);
|
|
38
|
+
return () => this.#listeners.delete(listener);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async prompt(text: string): Promise<void> {
|
|
42
|
+
this.prompts.push(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async steer(text: string): Promise<void> {
|
|
46
|
+
this.steers.push(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async abort(): Promise<void> {
|
|
50
|
+
this.aborts += 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
startResponse(text: string): void {
|
|
54
|
+
this.#emit({ type: "agent_start" });
|
|
55
|
+
this.#emit({ type: "message_start", message: { role: "assistant" } });
|
|
56
|
+
this.#emit({
|
|
57
|
+
type: "message_update",
|
|
58
|
+
message: { role: "assistant" },
|
|
59
|
+
assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: text },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
finishResponse(): void {
|
|
64
|
+
this.#emit({ type: "message_end", message: { role: "assistant" } });
|
|
65
|
+
this.#emit({ type: "agent_end", messages: [], willRetry: false });
|
|
66
|
+
this.#emit({ type: "agent_settled" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#emit(event: object): void {
|
|
70
|
+
for (const listener of this.#listeners) {
|
|
71
|
+
listener(event as AgentSessionEvent);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ActiveTurnFixture {
|
|
77
|
+
readonly backend: ControlledBackend;
|
|
78
|
+
readonly baseline: number;
|
|
79
|
+
readonly chat: string;
|
|
80
|
+
readonly client: AhpClient;
|
|
81
|
+
readonly harness: Harness;
|
|
82
|
+
readonly session: string;
|
|
83
|
+
readonly workspace: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function nextEnvelope(
|
|
87
|
+
subscription: Subscription,
|
|
88
|
+
matches: (envelope: ActionEnvelope) => boolean,
|
|
89
|
+
timeoutMs = 2_000,
|
|
90
|
+
): Promise<ActionEnvelope> {
|
|
91
|
+
let handle: ReturnType<typeof setTimeout>;
|
|
92
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
93
|
+
handle = setTimeout(() => reject(new Error("no matching action arrived")), timeoutMs);
|
|
94
|
+
handle.unref?.();
|
|
95
|
+
});
|
|
96
|
+
const next = (async () => {
|
|
97
|
+
while (true) {
|
|
98
|
+
const event = await subscription.next();
|
|
99
|
+
if (event.done) {
|
|
100
|
+
throw new Error("subscription ended early");
|
|
101
|
+
}
|
|
102
|
+
if (event.value.type === "action") {
|
|
103
|
+
const envelope = event.value.params as ActionEnvelope;
|
|
104
|
+
if (matches(envelope)) {
|
|
105
|
+
return envelope;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
return Promise.race([next, timeout]).finally(() => clearTimeout(handle));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function startActiveTurn(replayBufferCapacity = 64): Promise<ActiveTurnFixture> {
|
|
114
|
+
const workspace = mkdtempSync(join(tmpdir(), "pi-ahp-active-reconnect-"));
|
|
115
|
+
const backend = new ControlledBackend();
|
|
116
|
+
const harness = await startHarness({
|
|
117
|
+
sessions: true,
|
|
118
|
+
workingDirectory: workspace,
|
|
119
|
+
replayBufferCapacity,
|
|
120
|
+
createBackend: () => backend,
|
|
121
|
+
});
|
|
122
|
+
const client = await harness.connect();
|
|
123
|
+
await client.initialize({ clientId: CLIENT_ID, protocolVersions: SUPPORTED_PROTOCOL_VERSIONS });
|
|
124
|
+
|
|
125
|
+
const id = randomUUID();
|
|
126
|
+
const session = sessionUri(id);
|
|
127
|
+
const chat = chatUri(id);
|
|
128
|
+
await client.request("createSession", { channel: session });
|
|
129
|
+
await client.subscribe(session);
|
|
130
|
+
await client.subscribe(chat);
|
|
131
|
+
client.dispatch(chat, {
|
|
132
|
+
type: ActionType.ChatTurnStarted,
|
|
133
|
+
turnId: TURN_ID,
|
|
134
|
+
startedAt: new Date().toISOString(),
|
|
135
|
+
message: { text: "keep working", origin: { kind: MessageKind.User } },
|
|
136
|
+
});
|
|
137
|
+
await eventually("the initial prompt to reach the backend", () => backend.prompts.length === 1);
|
|
138
|
+
assert.equal((harness.host.store.get(chat) as ChatState).activeTurn?.id, TURN_ID);
|
|
139
|
+
|
|
140
|
+
return { backend, baseline: harness.host.serverSeq, chat, client, harness, session, workspace };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dispose(fixture: ActiveTurnFixture): Promise<void> {
|
|
144
|
+
await fixture.harness.dispose();
|
|
145
|
+
rmSync(fixture.workspace, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function responseText(state: ChatState): string | undefined {
|
|
149
|
+
const turn = state.activeTurn ?? state.turns.at(-1);
|
|
150
|
+
const part = turn?.responseParts.find((candidate) => candidate.kind === ResponsePartKind.Markdown);
|
|
151
|
+
return part?.kind === ResponsePartKind.Markdown ? part.content : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe("active-turn reconnect", () => {
|
|
155
|
+
it("replays offline output and accepts steering and cancellation after reconnect", async () => {
|
|
156
|
+
const fixture = await startActiveTurn();
|
|
157
|
+
try {
|
|
158
|
+
const observer = await fixture.harness.connect();
|
|
159
|
+
await observer.initialize({ clientId: nextClientId(), protocolVersions: SUPPORTED_PROTOCOL_VERSIONS });
|
|
160
|
+
const { subscription: observerEvents } = await observer.subscribe(fixture.chat);
|
|
161
|
+
|
|
162
|
+
await fixture.client.shutdown();
|
|
163
|
+
await eventually(
|
|
164
|
+
"only the observer to remain subscribed",
|
|
165
|
+
() => fixture.harness.host.subscriberCount(fixture.chat) === 1,
|
|
166
|
+
);
|
|
167
|
+
fixture.backend.startResponse("offline partial");
|
|
168
|
+
const observed = await nextEnvelope(
|
|
169
|
+
observerEvents,
|
|
170
|
+
(envelope) => envelope.action.type === ActionType.ChatResponsePart,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const resumed = await fixture.harness.connect();
|
|
174
|
+
const result = (await resumed.reconnect({
|
|
175
|
+
clientId: CLIENT_ID,
|
|
176
|
+
lastSeenServerSeq: fixture.baseline,
|
|
177
|
+
subscriptions: [fixture.session, fixture.chat],
|
|
178
|
+
})) as ReconnectReplayResult;
|
|
179
|
+
|
|
180
|
+
assert.equal(result.type, ReconnectResultType.Replay);
|
|
181
|
+
assert.ok(result.actions.some((envelope) => envelope.serverSeq === observed.serverSeq));
|
|
182
|
+
assert.equal(responseText(fixture.harness.host.store.get(fixture.chat) as ChatState), "offline partial");
|
|
183
|
+
assert.deepEqual(fixture.backend.prompts, ["keep working"]);
|
|
184
|
+
|
|
185
|
+
resumed.dispatch(fixture.chat, {
|
|
186
|
+
type: ActionType.ChatPendingMessageSet,
|
|
187
|
+
kind: PendingMessageKind.Steering,
|
|
188
|
+
id: "after-reconnect",
|
|
189
|
+
message: { text: "focus here", origin: { kind: MessageKind.User } },
|
|
190
|
+
});
|
|
191
|
+
await eventually("reconnected steering to reach the backend", () => fixture.backend.steers.length === 1);
|
|
192
|
+
resumed.dispatch(fixture.chat, {
|
|
193
|
+
type: ActionType.ChatTurnCancelled,
|
|
194
|
+
turnId: TURN_ID,
|
|
195
|
+
duration: 0,
|
|
196
|
+
});
|
|
197
|
+
await eventually("reconnected cancellation to reach the backend", () => fixture.backend.aborts === 1);
|
|
198
|
+
|
|
199
|
+
const state = fixture.harness.host.store.get(fixture.chat) as ChatState;
|
|
200
|
+
assert.equal(state.activeTurn, undefined);
|
|
201
|
+
assert.equal(state.turns.at(-1)?.state, TurnState.Cancelled);
|
|
202
|
+
} finally {
|
|
203
|
+
await dispose(fixture);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("replays a turn that completed while its client was offline", async () => {
|
|
208
|
+
const fixture = await startActiveTurn();
|
|
209
|
+
try {
|
|
210
|
+
await fixture.client.shutdown();
|
|
211
|
+
await eventually(
|
|
212
|
+
"the original chat subscription to disconnect",
|
|
213
|
+
() => fixture.harness.host.subscriberCount(fixture.chat) === 0,
|
|
214
|
+
);
|
|
215
|
+
fixture.backend.startResponse("finished offline");
|
|
216
|
+
fixture.backend.finishResponse();
|
|
217
|
+
|
|
218
|
+
const resumed = await fixture.harness.connect();
|
|
219
|
+
const result = (await resumed.reconnect({
|
|
220
|
+
clientId: CLIENT_ID,
|
|
221
|
+
lastSeenServerSeq: fixture.baseline,
|
|
222
|
+
subscriptions: [fixture.session, fixture.chat],
|
|
223
|
+
})) as ReconnectReplayResult;
|
|
224
|
+
|
|
225
|
+
assert.equal(result.type, ReconnectResultType.Replay);
|
|
226
|
+
assert.ok(result.actions.some((envelope) => envelope.action.type === ActionType.ChatTurnComplete));
|
|
227
|
+
const state = fixture.harness.host.store.get(fixture.chat) as ChatState;
|
|
228
|
+
assert.equal(state.turns.at(-1)?.state, TurnState.Complete);
|
|
229
|
+
assert.equal(responseText(state), "finished offline");
|
|
230
|
+
assert.deepEqual(fixture.backend.prompts, ["keep working"]);
|
|
231
|
+
} finally {
|
|
232
|
+
await dispose(fixture);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("falls back to an active-turn snapshot and continues streaming", async () => {
|
|
237
|
+
const fixture = await startActiveTurn(4);
|
|
238
|
+
try {
|
|
239
|
+
await fixture.client.shutdown();
|
|
240
|
+
await eventually(
|
|
241
|
+
"the original chat subscription to disconnect",
|
|
242
|
+
() => fixture.harness.host.subscriberCount(fixture.chat) === 0,
|
|
243
|
+
);
|
|
244
|
+
fixture.backend.startResponse("snapshot partial");
|
|
245
|
+
for (let i = 0; i < 6; i++) {
|
|
246
|
+
fixture.harness.host.dispatchServerAction(ROOT_CHANNEL, {
|
|
247
|
+
type: ActionType.RootActiveSessionsChanged,
|
|
248
|
+
activeSessions: i,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const resumed = await fixture.harness.connect();
|
|
253
|
+
const resumedEvents = resumed.attachSubscription(fixture.chat);
|
|
254
|
+
const result = (await resumed.reconnect({
|
|
255
|
+
clientId: CLIENT_ID,
|
|
256
|
+
lastSeenServerSeq: fixture.baseline,
|
|
257
|
+
subscriptions: [fixture.session, fixture.chat],
|
|
258
|
+
})) as ReconnectSnapshotResult;
|
|
259
|
+
|
|
260
|
+
assert.equal(result.type, ReconnectResultType.Snapshot);
|
|
261
|
+
const chatSnapshot = result.snapshots.find((snapshot) => snapshot.resource === fixture.chat);
|
|
262
|
+
assert.ok(chatSnapshot);
|
|
263
|
+
assert.equal(responseText(chatSnapshot.state as ChatState), "snapshot partial");
|
|
264
|
+
fixture.backend.finishResponse();
|
|
265
|
+
await nextEnvelope(resumedEvents, (envelope) => envelope.action.type === ActionType.ChatTurnComplete);
|
|
266
|
+
|
|
267
|
+
const state = fixture.harness.host.store.get(fixture.chat) as ChatState;
|
|
268
|
+
assert.equal(state.turns.at(-1)?.state, TurnState.Complete);
|
|
269
|
+
assert.deepEqual(fixture.backend.prompts, ["keep working"]);
|
|
270
|
+
} finally {
|
|
271
|
+
await dispose(fixture);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|