@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,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives one chat channel against a pi agent session.
|
|
3
|
+
*
|
|
4
|
+
* The host owns the turn: a client's `chat/turnStarted` is reduced into
|
|
5
|
+
* authoritative state first, then handed to the backend. Everything the agent
|
|
6
|
+
* emits comes back through {@link TurnMapper} and is dispatched as
|
|
7
|
+
* host-originated actions, so every subscribed client sees the same stream in
|
|
8
|
+
* the same order.
|
|
9
|
+
*
|
|
10
|
+
* Queued messages deliberately never reach pi. The protocol's own state *is*
|
|
11
|
+
* the queue — `ChatState.queuedMessages`, with ids the client chose — and the
|
|
12
|
+
* host consumes the head on idle by dispatching a `chat/turnStarted` carrying
|
|
13
|
+
* `queuedMessageId`, which the reducer applies atomically (create the turn,
|
|
14
|
+
* drop the queue entry). Pushing the queue down to pi as well would duplicate
|
|
15
|
+
* the state and, because pi consumes follow-ups *inside* the same agent run,
|
|
16
|
+
* would silently merge two user messages into one turn.
|
|
17
|
+
*
|
|
18
|
+
* @see https://microsoft.github.io/agent-host-protocol/specification/chat-channel
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isDeepStrictEqual } from "node:util";
|
|
22
|
+
import type { ImageContent } from "@earendil-works/pi-ai";
|
|
23
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import {
|
|
25
|
+
ActionType,
|
|
26
|
+
type ChatState,
|
|
27
|
+
type Message,
|
|
28
|
+
MessageKind,
|
|
29
|
+
type ModelSelection,
|
|
30
|
+
PendingMessageKind,
|
|
31
|
+
type StateAction,
|
|
32
|
+
type URI,
|
|
33
|
+
} from "@microsoft/agent-host-protocol";
|
|
34
|
+
import type { AhpHost } from "../core/host.ts";
|
|
35
|
+
import { TurnMapper } from "./event-mapper.ts";
|
|
36
|
+
import { messageInputForPi } from "./message-input.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slice of a pi agent session this host needs.
|
|
40
|
+
*
|
|
41
|
+
* Narrow on purpose: the in-process `AgentSession` adapter and a possible
|
|
42
|
+
* subprocess driver can satisfy it without the mapper or driver changing.
|
|
43
|
+
*/
|
|
44
|
+
export interface PiBackend {
|
|
45
|
+
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
|
|
46
|
+
/** The signal prevents accepted preflight work from starting after its turn was cancelled. */
|
|
47
|
+
prompt(text: string, images?: ImageContent[], signal?: AbortSignal): Promise<void>;
|
|
48
|
+
/** Queues steering only while the owning turn's signal remains live. */
|
|
49
|
+
steer(text: string, images?: ImageContent[], signal?: AbortSignal): Promise<void>;
|
|
50
|
+
/** Stops work that has already entered the backend. */
|
|
51
|
+
abort(): Promise<void>;
|
|
52
|
+
dispose?(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Applies a per-message model choice before the turn runs.
|
|
55
|
+
*
|
|
56
|
+
* The protocol carries model selection on the message
|
|
57
|
+
* ({@link Message.model}), not in session config, so a client may switch
|
|
58
|
+
* model or reasoning effort on any turn.
|
|
59
|
+
*/
|
|
60
|
+
selectModel?(selection: ModelSelection): Promise<void>;
|
|
61
|
+
/** The model and reasoning effort a new message would use by default. */
|
|
62
|
+
currentSelection?(): ModelSelection | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Moves the conversation back to an earlier point.
|
|
65
|
+
*
|
|
66
|
+
* Returns `false` when the agent declined (an extension may veto), which
|
|
67
|
+
* the caller must surface rather than swallow — the protocol state has
|
|
68
|
+
* already been truncated by then.
|
|
69
|
+
*/
|
|
70
|
+
truncate?(entryId: string): Promise<boolean>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ChatDriverOptions {
|
|
74
|
+
readonly host: AhpHost;
|
|
75
|
+
readonly chatChannel: URI;
|
|
76
|
+
readonly backend: PiBackend;
|
|
77
|
+
readonly workingDirectory?: string;
|
|
78
|
+
/** Called with a turn id once that turn has finished, to anchor truncation. */
|
|
79
|
+
readonly recordTurnAnchor?: (turnId: string) => void;
|
|
80
|
+
readonly log?: (message: string) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class ChatDriver {
|
|
84
|
+
readonly #host: AhpHost;
|
|
85
|
+
readonly #chatChannel: URI;
|
|
86
|
+
readonly #backend: PiBackend;
|
|
87
|
+
/** Used to render tool paths relative to the workspace in activity strings. */
|
|
88
|
+
readonly #workingDirectory: string | undefined;
|
|
89
|
+
readonly #recordTurnAnchor: ((turnId: string) => void) | undefined;
|
|
90
|
+
readonly #log: ((message: string) => void) | undefined;
|
|
91
|
+
readonly #unsubscribe: () => void;
|
|
92
|
+
|
|
93
|
+
#mapper: TurnMapper | undefined;
|
|
94
|
+
/** Cancels preflight work before a cancelled turn can enter AgentSession. */
|
|
95
|
+
#turnAbort: AbortController | undefined;
|
|
96
|
+
/** Preflight/run work belonging to the current mapper, if it has not settled. */
|
|
97
|
+
#turnOperation: Promise<void> | undefined;
|
|
98
|
+
/** A client cancellation must settle before another turn starts on the backend. */
|
|
99
|
+
#cancellation: Promise<void> | undefined;
|
|
100
|
+
/** Prevents an older chained cancellation from releasing a newer one's barrier. */
|
|
101
|
+
#cancellationGeneration = 0;
|
|
102
|
+
/** Turn ids whose user `message_end` pi emitted and will persist. */
|
|
103
|
+
readonly #persistedInputTurns = new Set<string>();
|
|
104
|
+
/** Backend mutations already accepted by this driver but not yet settled. */
|
|
105
|
+
readonly #inFlight = new Set<Promise<unknown>>();
|
|
106
|
+
/** Stops new mutations while the owner decides whether to remove the session. */
|
|
107
|
+
#quiesced = false;
|
|
108
|
+
/** Id of the steering message pi has been handed but not yet consumed. */
|
|
109
|
+
#pendingSteeringId: string | undefined;
|
|
110
|
+
/** Last steering-queue length pi reported, to detect a consumption. */
|
|
111
|
+
#steeringQueueLength = 0;
|
|
112
|
+
|
|
113
|
+
constructor(options: ChatDriverOptions) {
|
|
114
|
+
this.#host = options.host;
|
|
115
|
+
this.#chatChannel = options.chatChannel;
|
|
116
|
+
this.#backend = options.backend;
|
|
117
|
+
this.#workingDirectory = options.workingDirectory;
|
|
118
|
+
this.#recordTurnAnchor = options.recordTurnAnchor;
|
|
119
|
+
this.#log = options.log;
|
|
120
|
+
this.#unsubscribe = options.backend.subscribe((event) => {
|
|
121
|
+
this.#onAgentEvent(event);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get chatChannel(): URI {
|
|
126
|
+
return this.#chatChannel;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether a turn is currently mapping agent output. */
|
|
130
|
+
get busy(): boolean {
|
|
131
|
+
return this.#mapper !== undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Stops new work, aborts the active run, and waits for accepted mutations to settle. */
|
|
135
|
+
async quiesce(): Promise<void> {
|
|
136
|
+
this.#quiesced = true;
|
|
137
|
+
const turnAbort = this.#turnAbort;
|
|
138
|
+
try {
|
|
139
|
+
await this.#backend.abort();
|
|
140
|
+
// Abort preflight only after backend abort succeeds, so a failed
|
|
141
|
+
// disposal can still resume the turn it tried to quiesce.
|
|
142
|
+
turnAbort?.abort();
|
|
143
|
+
await this.#drainInFlight();
|
|
144
|
+
// A backend is required to settle after abort, but a narrow test or future
|
|
145
|
+
// backend may only honour the Promise boundary.
|
|
146
|
+
this.#finishTurn("cancelled");
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.resume();
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reopens the driver after the operation that requested quiescence failed. */
|
|
154
|
+
resume(): void {
|
|
155
|
+
if (!this.#quiesced) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.#quiesced = false;
|
|
159
|
+
this.#consumeNextQueuedMessage();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Final cleanup is best-effort; callers may already have removed durable state. */
|
|
163
|
+
dispose(): void {
|
|
164
|
+
try {
|
|
165
|
+
this.#unsubscribe();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.#log?.(`backend unsubscribe failed: ${String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
this.#backend.dispose?.();
|
|
171
|
+
} catch (error) {
|
|
172
|
+
this.#log?.(`backend disposal failed: ${String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Asks the agent to move its conversation back to `entryId`. */
|
|
177
|
+
truncate(entryId: string): Promise<boolean> {
|
|
178
|
+
return this.#track(this.#truncate(entryId));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async #truncate(entryId: string): Promise<boolean> {
|
|
182
|
+
if (!this.#backend.truncate) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return await this.#backend.truncate(entryId);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.#log?.(`truncate failed: ${String(error)}`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Client actions ──────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reacts to a client action already applied to state.
|
|
197
|
+
*
|
|
198
|
+
* Returns `true` when the action belonged to this chat, so the caller can
|
|
199
|
+
* tell handled actions from ones meant for another channel.
|
|
200
|
+
*/
|
|
201
|
+
handleClientAction(channel: URI, action: StateAction): boolean {
|
|
202
|
+
if (channel !== this.#chatChannel) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
switch (action.type) {
|
|
206
|
+
case ActionType.ChatTurnStarted:
|
|
207
|
+
this.#startTurn(action.turnId, action.message);
|
|
208
|
+
return true;
|
|
209
|
+
case ActionType.ChatTurnCancelled:
|
|
210
|
+
this.#cancelTurn(action.turnId);
|
|
211
|
+
return true;
|
|
212
|
+
case ActionType.ChatPendingMessageSet:
|
|
213
|
+
// Steering is the only kind pi needs to know about: it is
|
|
214
|
+
// injected into the run already in flight. Queued messages stay
|
|
215
|
+
// in protocol state until this host consumes them.
|
|
216
|
+
if (action.kind === PendingMessageKind.Steering) {
|
|
217
|
+
// Remember the id so the pending message can be cleared once
|
|
218
|
+
// pi actually injects it — the client shows it as waiting
|
|
219
|
+
// until then.
|
|
220
|
+
this.#pendingSteeringId = action.id;
|
|
221
|
+
void this.#track(this.#steer(action.message));
|
|
222
|
+
} else {
|
|
223
|
+
// A message queued while the chat is idle is consumed straight
|
|
224
|
+
// away; otherwise it waits for the running turn to finish.
|
|
225
|
+
this.#consumeNextQueuedMessage();
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
default:
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Turn lifecycle ──────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
async #steer(message: Message): Promise<void> {
|
|
236
|
+
const signal = this.#turnAbort?.signal;
|
|
237
|
+
try {
|
|
238
|
+
const cancellation = this.#cancellation;
|
|
239
|
+
if (cancellation) {
|
|
240
|
+
await cancellation;
|
|
241
|
+
}
|
|
242
|
+
if (signal?.aborted) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const input = messageInputForPi(message);
|
|
246
|
+
await this.#backend.steer(input.text, input.images, signal);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.#log?.(`steer failed: ${String(error)}`);
|
|
249
|
+
this.#clearPendingSteering();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#startTurn(turnId: string, message: Message): void {
|
|
254
|
+
this.#persistedInputTurns.clear();
|
|
255
|
+
const mapper = new TurnMapper(turnId, Date.now(), {
|
|
256
|
+
...(this.#workingDirectory ? { workingDirectory: this.#workingDirectory } : {}),
|
|
257
|
+
});
|
|
258
|
+
const controller = new AbortController();
|
|
259
|
+
this.#mapper = mapper;
|
|
260
|
+
this.#turnAbort = controller;
|
|
261
|
+
this.#runPrompt(mapper, message, "prompt", controller.signal);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#runPrompt(mapper: TurnMapper, message: Message, label: "prompt" | "queued prompt", signal: AbortSignal): void {
|
|
265
|
+
const operation = (async () => {
|
|
266
|
+
const cancellation = this.#cancellation;
|
|
267
|
+
if (cancellation) {
|
|
268
|
+
await cancellation;
|
|
269
|
+
}
|
|
270
|
+
if (this.#mapper !== mapper || this.#quiesced || signal.aborted) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// The client's choice has to land before the prompt, or the turn runs on
|
|
274
|
+
// whatever the previous one used. Selection may itself append to the
|
|
275
|
+
// session, so quiescence waits for this whole operation rather than only
|
|
276
|
+
// guarding the eventual prompt call.
|
|
277
|
+
if (message.model && this.#backend.selectModel) {
|
|
278
|
+
try {
|
|
279
|
+
await this.#backend.selectModel(message.model);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
this.#log?.(`selectModel failed: ${String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (this.#mapper !== mapper || this.#quiesced || signal.aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const input = messageInputForPi(message);
|
|
289
|
+
await this.#backend.prompt(input.text, input.images, signal);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (this.#mapper !== mapper) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// `prompt()` can reject before any agent event. Nothing will emit
|
|
295
|
+
// `agent_settled`, so close the turn explicitly.
|
|
296
|
+
this.#log?.(`${label} failed: ${String(error)}`);
|
|
297
|
+
this.#finishTurn("error", error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
})();
|
|
300
|
+
const tracked = this.#track(operation);
|
|
301
|
+
this.#turnOperation = tracked;
|
|
302
|
+
const forget = (): void => {
|
|
303
|
+
if (this.#turnOperation === tracked) this.#turnOperation = undefined;
|
|
304
|
+
};
|
|
305
|
+
void tracked.then(forget, forget);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#onAgentEvent(event: AgentSessionEvent): void {
|
|
309
|
+
// Handled before the active-turn check: a steering message is consumed
|
|
310
|
+
// during a run, but the queue can also be cleared outside one.
|
|
311
|
+
if (event.type === "queue_update") {
|
|
312
|
+
this.#reconcileSteering(event as { steering: readonly string[] });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const mapper = this.#mapper;
|
|
317
|
+
if (!mapper) {
|
|
318
|
+
// Events outside a turn (a stray settle after cancellation, or
|
|
319
|
+
// activity pi started on its own) have no turn to attach to.
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const action of mapper.handle(event)) {
|
|
324
|
+
this.#host.dispatchServerAction(this.#chatChannel, action);
|
|
325
|
+
}
|
|
326
|
+
if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "user") {
|
|
327
|
+
// AgentSession appends this message immediately after notifying its
|
|
328
|
+
// listeners, before another client action can be handled.
|
|
329
|
+
this.#persistedInputTurns.add(mapper.turnId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (mapper.finished) {
|
|
333
|
+
this.#recordTurnAnchor?.(mapper.turnId);
|
|
334
|
+
this.#persistedInputTurns.clear();
|
|
335
|
+
this.#mapper = undefined;
|
|
336
|
+
this.#turnAbort = undefined;
|
|
337
|
+
this.#consumeNextQueuedMessage();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Clears the pending steering message once pi has taken it.
|
|
343
|
+
*
|
|
344
|
+
* pi removes a queued message from its own queue immediately before the
|
|
345
|
+
* `message_start` that injects it, emitting `queue_update` at that point
|
|
346
|
+
* (`agent-session.ts`). Without mirroring that into
|
|
347
|
+
* `chat/pendingMessageRemoved`, `ChatState.steeringMessage` never clears and
|
|
348
|
+
* the client shows the message as forever unsent — even though the model
|
|
349
|
+
* already received it.
|
|
350
|
+
*/
|
|
351
|
+
#reconcileSteering(event: { steering: readonly string[] }): void {
|
|
352
|
+
const length = event.steering?.length ?? 0;
|
|
353
|
+
const consumed = length < this.#steeringQueueLength;
|
|
354
|
+
this.#steeringQueueLength = length;
|
|
355
|
+
if (consumed) {
|
|
356
|
+
this.#clearPendingSteering();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#clearPendingSteering(): void {
|
|
361
|
+
const id = this.#pendingSteeringId;
|
|
362
|
+
if (!id) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.#pendingSteeringId = undefined;
|
|
366
|
+
|
|
367
|
+
// The injected text is not recorded here: pi delivers it as an ordinary
|
|
368
|
+
// user message, which the mapper turns into its own turn — the same
|
|
369
|
+
// shape it will have when the session is later rebuilt from disk.
|
|
370
|
+
this.#host.dispatchServerAction(this.#chatChannel, {
|
|
371
|
+
type: ActionType.ChatPendingMessageRemoved,
|
|
372
|
+
kind: PendingMessageKind.Steering,
|
|
373
|
+
id,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#finishTurn(outcome: "complete" | "cancelled" | "error", message?: string): void {
|
|
378
|
+
const mapper = this.#mapper;
|
|
379
|
+
if (!mapper) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
for (const action of mapper.finish(outcome, message)) {
|
|
383
|
+
this.#host.dispatchServerAction(this.#chatChannel, action);
|
|
384
|
+
}
|
|
385
|
+
this.#recordAnchorIfPersisted(mapper.turnId);
|
|
386
|
+
this.#persistedInputTurns.clear();
|
|
387
|
+
this.#mapper = undefined;
|
|
388
|
+
this.#turnAbort = undefined;
|
|
389
|
+
// A turn that ends without consuming its steering message must not leave
|
|
390
|
+
// it pending for the next one.
|
|
391
|
+
this.#clearPendingSteering();
|
|
392
|
+
this.#consumeNextQueuedMessage();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Starts the next queued message, if any.
|
|
397
|
+
*
|
|
398
|
+
* The `queuedMessageId` is what makes this atomic: the reducer creates the
|
|
399
|
+
* active turn and removes that entry from `queuedMessages` in one step, so
|
|
400
|
+
* no client can observe a state where the message is both queued and
|
|
401
|
+
* running.
|
|
402
|
+
*/
|
|
403
|
+
#consumeNextQueuedMessage(): void {
|
|
404
|
+
if (this.#quiesced || this.#cancellation) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const state = this.#host.store.get(this.#chatChannel) as ChatState | undefined;
|
|
408
|
+
const next = state?.queuedMessages?.[0];
|
|
409
|
+
if (!next || this.#mapper) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const turnId = `turn-${next.id}`;
|
|
414
|
+
this.#persistedInputTurns.clear();
|
|
415
|
+
const mapper = new TurnMapper(turnId, Date.now(), {
|
|
416
|
+
...(this.#workingDirectory ? { workingDirectory: this.#workingDirectory } : {}),
|
|
417
|
+
});
|
|
418
|
+
const controller = new AbortController();
|
|
419
|
+
this.#mapper = mapper;
|
|
420
|
+
this.#turnAbort = controller;
|
|
421
|
+
this.#host.dispatchServerAction(this.#chatChannel, {
|
|
422
|
+
type: ActionType.ChatTurnStarted,
|
|
423
|
+
turnId,
|
|
424
|
+
startedAt: new Date().toISOString(),
|
|
425
|
+
message: next.message,
|
|
426
|
+
queuedMessageId: next.id,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
this.#runPrompt(mapper, next.message, "queued prompt", controller.signal);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#cancelTurn(turnId: string): void {
|
|
433
|
+
const mapper = this.#mapper;
|
|
434
|
+
if (mapper && mapper.turnId !== turnId) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const turnOperation = this.#turnOperation;
|
|
438
|
+
const inputPersisted = this.#persistedInputTurns.has(turnId);
|
|
439
|
+
this.#persistedInputTurns.clear();
|
|
440
|
+
|
|
441
|
+
this.#turnAbort?.abort();
|
|
442
|
+
this.#turnAbort = undefined;
|
|
443
|
+
this.#mapper = undefined;
|
|
444
|
+
this.#clearPendingSteering();
|
|
445
|
+
|
|
446
|
+
const state = this.#host.store.get(this.#chatChannel) as ChatState | undefined;
|
|
447
|
+
if (state?.activity !== undefined) {
|
|
448
|
+
this.#host.dispatchServerAction(this.#chatChannel, { type: ActionType.ChatActivityChanged });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const generation = ++this.#cancellationGeneration;
|
|
452
|
+
const cancellation = this.#settleCancellation(this.#cancellation, turnOperation).finally(() => {
|
|
453
|
+
if (inputPersisted) {
|
|
454
|
+
this.#recordTurnAnchor?.(turnId);
|
|
455
|
+
}
|
|
456
|
+
if (generation !== this.#cancellationGeneration) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.#cancellation = undefined;
|
|
460
|
+
this.#consumeNextQueuedMessage();
|
|
461
|
+
});
|
|
462
|
+
this.#cancellation = cancellation;
|
|
463
|
+
void this.#track(cancellation);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async #settleCancellation(
|
|
467
|
+
previous: Promise<void> | undefined,
|
|
468
|
+
turnOperation: Promise<void> | undefined,
|
|
469
|
+
): Promise<void> {
|
|
470
|
+
await previous;
|
|
471
|
+
await Promise.all([
|
|
472
|
+
this.#backend.abort().catch((error: unknown) => {
|
|
473
|
+
this.#log?.(`abort failed: ${String(error)}`);
|
|
474
|
+
}),
|
|
475
|
+
turnOperation?.catch((error: unknown) => {
|
|
476
|
+
this.#log?.(`cancelled turn cleanup failed: ${String(error)}`);
|
|
477
|
+
}),
|
|
478
|
+
]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#recordAnchorIfPersisted(turnId: string): void {
|
|
482
|
+
if (this.#persistedInputTurns.has(turnId)) {
|
|
483
|
+
this.#recordTurnAnchor?.(turnId);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#track<T>(operation: Promise<T>): Promise<T> {
|
|
488
|
+
let tracked: Promise<T>;
|
|
489
|
+
tracked = operation.finally(() => {
|
|
490
|
+
this.#inFlight.delete(tracked);
|
|
491
|
+
});
|
|
492
|
+
this.#inFlight.add(tracked);
|
|
493
|
+
return tracked;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async #drainInFlight(): Promise<void> {
|
|
497
|
+
while (this.#inFlight.size > 0) {
|
|
498
|
+
await Promise.allSettled([...this.#inFlight]);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Publishes the model a new message would use as the chat's draft.
|
|
504
|
+
*
|
|
505
|
+
* There is no protocol field for "the default model", but a client is told
|
|
506
|
+
* to initialise its input from {@link ChatState.draft}. Seeding the draft's
|
|
507
|
+
* model selection is therefore how a host says which model and reasoning
|
|
508
|
+
* effort are actually in effect — without it the client has to guess, and
|
|
509
|
+
* will guess wrong.
|
|
510
|
+
*/
|
|
511
|
+
publishDefaultSelection(): void {
|
|
512
|
+
const selection = this.#backend.currentSelection?.();
|
|
513
|
+
if (!selection) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const state = this.#host.store.get(this.#chatChannel) as ChatState | undefined;
|
|
517
|
+
if (isDeepStrictEqual(state?.draft?.model, selection)) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.#host.dispatchServerAction(this.#chatChannel, {
|
|
521
|
+
type: ActionType.ChatDraftChanged,
|
|
522
|
+
draft: {
|
|
523
|
+
text: state?.draft?.text ?? "",
|
|
524
|
+
origin: { kind: MessageKind.User },
|
|
525
|
+
model: selection,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline completions for a message being composed — the `completions` command.
|
|
3
|
+
*
|
|
4
|
+
* Note what the protocol's shape implies: every `CompletionItem` carries a
|
|
5
|
+
* **required** `attachment`. Completions here are therefore specifically about
|
|
6
|
+
* producing attachments — the `@`-mention picker — not about generic text
|
|
7
|
+
* completion.
|
|
8
|
+
*
|
|
9
|
+
* That is why pi's slash commands are absent. An extension command, prompt
|
|
10
|
+
* template or skill (`/skill:brave-search`) attaches nothing; it is text pi
|
|
11
|
+
* expands server-side when the message is sent. Dressing one up as a
|
|
12
|
+
* `simple` attachment would leave a meaningless chip on the message, so this
|
|
13
|
+
* host advertises `@` alone as a trigger character and lets `/…` be typed
|
|
14
|
+
* plainly — it still works, just without completion assistance.
|
|
15
|
+
*
|
|
16
|
+
* @see https://microsoft.github.io/agent-host-protocol/specification/chat-channel
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { glob } from "node:fs/promises";
|
|
20
|
+
import { basename, join, relative, sep } from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
type CompletionItem,
|
|
24
|
+
CompletionItemKind,
|
|
25
|
+
type CompletionsParams,
|
|
26
|
+
type CompletionsResult,
|
|
27
|
+
MessageAttachmentKind,
|
|
28
|
+
type URI,
|
|
29
|
+
} from "@microsoft/agent-host-protocol";
|
|
30
|
+
|
|
31
|
+
/** The character that opens a resource mention. */
|
|
32
|
+
export const MENTION_TRIGGER = "@";
|
|
33
|
+
|
|
34
|
+
/** Prunes high-volume implementation directories from the workspace walk. */
|
|
35
|
+
const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
|
|
36
|
+
|
|
37
|
+
/** Upper bound on returned items. The client renders a picker, not a file tree. */
|
|
38
|
+
const MAX_ITEMS = 50;
|
|
39
|
+
|
|
40
|
+
/** Keeps a completion request bounded even when the workspace is enormous. */
|
|
41
|
+
const MAX_SCANNED_ENTRIES = 10_000;
|
|
42
|
+
|
|
43
|
+
function score(relativePath: string, query: string): number {
|
|
44
|
+
if (!query) {
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const path = relativePath.toLowerCase();
|
|
48
|
+
const name = basename(path);
|
|
49
|
+
if (name === query) return 4;
|
|
50
|
+
if (name.startsWith(query)) return 3;
|
|
51
|
+
if (name.includes(query)) return 2;
|
|
52
|
+
return path.includes(query) ? 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CompletionServiceOptions {
|
|
56
|
+
/** Resolves the directory a chat's mentions are relative to. */
|
|
57
|
+
readonly workingDirectoryFor: (chat: URI) => string | undefined;
|
|
58
|
+
readonly maxItems?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MentionToken {
|
|
62
|
+
/** Offset of the `@`. */
|
|
63
|
+
readonly start: number;
|
|
64
|
+
/** Offset just past the token. */
|
|
65
|
+
readonly end: number;
|
|
66
|
+
/** Everything after the `@`. */
|
|
67
|
+
readonly query: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Finds the mention the cursor sits in, if any.
|
|
72
|
+
*
|
|
73
|
+
* A mention only counts at the start of the text or after whitespace, so an
|
|
74
|
+
* email address or a decorator does not trigger the picker.
|
|
75
|
+
*/
|
|
76
|
+
export function findMention(text: string, offset: number): MentionToken | undefined {
|
|
77
|
+
const clamped = Math.max(0, Math.min(offset, text.length));
|
|
78
|
+
let index = clamped;
|
|
79
|
+
while (index > 0) {
|
|
80
|
+
const char = text[index - 1] as string;
|
|
81
|
+
if (/\s/u.test(char)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
if (char === MENTION_TRIGGER) {
|
|
85
|
+
const preceding = index >= 2 ? (text[index - 2] as string) : undefined;
|
|
86
|
+
if (preceding !== undefined && !/\s/u.test(preceding)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return { start: index - 1, end: clamped, query: text.slice(index, clamped) };
|
|
90
|
+
}
|
|
91
|
+
index -= 1;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class CompletionService {
|
|
97
|
+
readonly #options: CompletionServiceOptions;
|
|
98
|
+
|
|
99
|
+
constructor(options: CompletionServiceOptions) {
|
|
100
|
+
this.#options = options;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Best-effort and prompt, as the protocol asks: anything that cannot be
|
|
105
|
+
* answered cheaply comes back as an empty list rather than an error, since
|
|
106
|
+
* a client debouncing keystrokes should never see a failed request for
|
|
107
|
+
* typing something odd.
|
|
108
|
+
*/
|
|
109
|
+
async complete(params: CompletionsParams): Promise<CompletionsResult> {
|
|
110
|
+
if (params.kind !== CompletionItemKind.UserMessage) return { items: [] };
|
|
111
|
+
const workingDirectory = this.#options.workingDirectoryFor(params.channel);
|
|
112
|
+
if (!workingDirectory) {
|
|
113
|
+
return { items: [] };
|
|
114
|
+
}
|
|
115
|
+
const mention = findMention(params.text ?? "", params.offset ?? 0);
|
|
116
|
+
if (!mention) {
|
|
117
|
+
return { items: [] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const query = mention.query.replaceAll("\\", "/").replace(/^\.\//u, "").toLowerCase();
|
|
121
|
+
const candidates: { absolute: string; relativePath: string; score: number }[] = [];
|
|
122
|
+
let scanned = 0;
|
|
123
|
+
try {
|
|
124
|
+
for await (const entry of glob("**/*", {
|
|
125
|
+
cwd: workingDirectory,
|
|
126
|
+
withFileTypes: true,
|
|
127
|
+
exclude: (candidate) => candidate.isDirectory() && SKIPPED_DIRECTORIES.has(candidate.name),
|
|
128
|
+
})) {
|
|
129
|
+
if (++scanned > MAX_SCANNED_ENTRIES) {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const absolute = join(entry.parentPath, entry.name);
|
|
136
|
+
const relativePath = relative(workingDirectory, absolute).split(sep).join("/");
|
|
137
|
+
const rank = score(relativePath, query);
|
|
138
|
+
if (rank) {
|
|
139
|
+
candidates.push({ absolute, relativePath, score: rank });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return { items: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
candidates.sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath));
|
|
147
|
+
const items: CompletionItem[] = candidates.slice(0, this.#options.maxItems ?? MAX_ITEMS).map((candidate) => ({
|
|
148
|
+
insertText: `${MENTION_TRIGGER}${candidate.relativePath}`,
|
|
149
|
+
rangeStart: mention.start,
|
|
150
|
+
rangeEnd: mention.end,
|
|
151
|
+
attachment: {
|
|
152
|
+
type: MessageAttachmentKind.Resource,
|
|
153
|
+
label: basename(candidate.relativePath),
|
|
154
|
+
displayKind: "document",
|
|
155
|
+
uri: pathToFileURL(candidate.absolute).toString(),
|
|
156
|
+
},
|
|
157
|
+
}));
|
|
158
|
+
return { items };
|
|
159
|
+
}
|
|
160
|
+
}
|