feat: add declarative Feature chat invocations and typed composers

This commit is contained in:
2026-10-05 18:44:41 +09:00
parent cc97987482
commit f7783b3d2a
69 changed files with 10065 additions and 657 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@8.3.2 preview",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
File diff suppressed because one or more lines are too long
+55 -5
View File
@@ -6,7 +6,52 @@ export type AlertLevel = "warn" | "error";
export type AlertSource = "worker" | "engine" | "compactor" | "agents_md";
export type CompletionKind = "file";
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CompletionContext = { invocation: FeatureInvocationIdentity, argument?: string | null, };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentType = { "kind": "string" } | { "kind": "integer" } | { "kind": "boolean" } | { "kind": "enum", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" };
export type InvocationCompletion = { "kind": "none" } | { "kind": "static", values: Array<string>, } | { "kind": "worker_file" } | { "kind": "client_file" } | { "kind": "provider", provider: string, };
export type InvocationArgumentDescriptor = { name: string,
/**
* Zero-based positional index. `None` means named-only. A positional
* argument may also be supplied by name; this makes chip editing stable.
*/
position?: number | null, required: boolean, value_type: InvocationArgumentType, completion: InvocationCompletion, description?: string | null, };
export type InvocationClientAdapter = "attachment";
export type FeatureInvocationDescriptor = { identity: FeatureInvocationIdentity, name: string, aliases: Array<string>, display_name: string, description: string, syntax: FeatureInvocationSyntax, arguments: Array<InvocationArgumentDescriptor>, client_adapter?: InvocationClientAdapter | null, };
export type InvocationValue = { "kind": "string", "value": string } | { "kind": "integer", "value": number } | { "kind": "boolean", "value": boolean };
export type InvocationArgumentValue = { name: string, value: InvocationValue, };
export type FeatureInvocation = {
/**
* Stable client-generated id retained across queueing and transport retry.
*/
invocation_id: string, identity: FeatureInvocationIdentity,
/**
* Selected public name, retained for exact draft/history restoration. Host
* resolution is always by `identity`.
*/
name: string, arguments: Array<InvocationArgumentValue>, };
export type FeatureInvocationStatus = "succeeded" | "failed" | "outcome_unknown";
export type FeatureInvocationResult = { invocation_id: string, identity: FeatureInvocationIdentity, status: FeatureInvocationStatus, message: string,
/**
* Bounded model-visible context produced by the handler. Credentials and
* executable implementation details must not be included.
*/
context?: string | null, };
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
@@ -108,7 +153,7 @@ recursive: boolean,
*/
symlink_policy: SymlinkPolicy, };
export type CompletionEntry = { value: string, is_dir: boolean, };
export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, };
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
@@ -270,7 +315,7 @@ export type PasteArtifactRef = { artifact_id: string, created_at_ms: number, med
*/
availability: PasteArtifactAvailability, byte_len: number, char_count: number, line_count: number, sha256: string, source_entry_id: string, };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "paste_artifact", artifact: PasteArtifactRef, } | { "kind": "uploaded_file", file: UploadedFileRef, } | { "kind": "file_ref", path: string, } | { "kind": "feature_invoke", invocation: FeatureInvocation, } | { "kind": "flow", selector: string, } | { "kind": "unknown" };
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
/**
@@ -367,7 +412,12 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string,
/**
* Client query generation. Echoed verbatim so identical-prefix ABA
* responses cannot cross edits, target switches, or permission changes.
*/
request_id?: string | null, context?: CompletionContext | null, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "notification_accepted", "data": { notification_request_id: string, } } | { "event": "notification_rejected", "data": { notification_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": {
/**
@@ -406,4 +456,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
+38 -4
View File
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -19,6 +19,12 @@ export type CleanupWorkdirFileStatus = "pending" | "present" | "active" | "clean
export type CleanupWorkerCandidate = { action: CleanupTargetKind; blocking_reason?: string | null; estimated_reclaim_bytes?: number | null; linked_workdir_ids: Array<string>; pinned: boolean; reason: string; retention_state: string; running_linked: boolean; runtime_id: string; runtime_worker_id: string; target_id: string; worker_id: string; };
export type CompletionContext = { argument?: string | null; invocation: FeatureInvocationIdentity; };
export type CompletionEntry = { description?: string | null; invocation?: FeatureInvocationDescriptor | null; is_dir?: boolean; usage?: string | null; value: string; };
export type CompletionKind = "file" | "feature" | "feature_argument";
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_revision?: number | null; public_bundle: RuntimePublicIdentityBundle; };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
@@ -29,6 +35,32 @@ export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?:
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationDescriptor = { aliases?: Array<string>; arguments?: Array<InvocationArgumentDescriptor>; client_adapter?: InvocationClientAdapter | null; description: string; display_name: string; identity: FeatureInvocationIdentity; name: string; syntax: FeatureInvocationSyntax; };
export type FeatureInvocationIdentity = string;
export type FeatureInvocationSyntax = "parenthesized";
export type InvocationArgumentDescriptor = { completion?: InvocationCompletion; description?: string | null; name: string; position?: number | null; required?: boolean; value_type: InvocationArgumentType; };
export type InvocationArgumentType = { kind: "string"; } | { kind: "integer"; } | { kind: "boolean"; } | { kind: "enum"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; };
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationClientAdapter = "attachment";
export type InvocationCompletion = { kind: "none"; } | { kind: "static"; values: Array<string>; } | { kind: "worker_file"; } | { kind: "client_file"; } | { kind: "provider"; provider: string; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; };
export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
@@ -93,11 +125,11 @@ export type RuntimeVerificationEvidenceSummary = { binding_revision: number; las
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
export type RuntimeWorkerCompletionsRequest = { kind: unknown; prefix?: string; };
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; };
export type RuntimeWorkerCompletionsResult = { diagnostics?: Array<Diagnostic>; entries: Array<unknown>; kind: unknown; prefix: string; runtime_id: string; worker_id: string; };
export type RuntimeWorkerCompletionsResult = { diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; runtime_id: string; worker_id: string; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<unknown> | null; };
export type RuntimeWorkerInputRequest = { content: string; kind?: string | null; segments?: Array<Segment> | null; };
export type RuntimeWorkerInputResult = { diagnostics?: Array<Diagnostic>; runtime_id: string; state: WorkerOperationState; worker_id: string; };
@@ -127,6 +159,8 @@ export type RuntimeWorkerSummary = { diagnostics?: Array<Diagnostic>; display_na
export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type UpdateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; };
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
// Input contract: 0.1.0 (sha256:0dc47aa72f9cace6e0026fbf5d9385f51b6d79c8f6a70a04954067a35e01c887)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -21,6 +21,14 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
export type FeatureInvocation = { arguments: Array<InvocationArgumentValue>; identity: FeatureInvocationIdentity; invocation_id: string; name: string; };
export type FeatureInvocationIdentity = string;
export type InvocationArgumentValue = { name: string; value: InvocationValue; };
export type InvocationValue = { kind: "string"; value: string; } | { kind: "integer"; value: number; } | { kind: "boolean"; value: boolean; };
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
export type PasteArtifactMediaType = "text_plain_utf8";
@@ -35,7 +43,7 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id
export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; };
export type SubjektivWorkerConnectionRequest = { subject_id: string; };
@@ -295,3 +295,20 @@ Deno.test("Worker create request parser requires the complete shared request", (
"kind is invalid",
);
});
Deno.test("Worker launch request preserves typed Feature invocations and rejects unsafe values", () => {
const invocation = { invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 1 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
] };
const request = { runtime_id: "runtime-a", display_name: "Worker A", profile: "builtin:coder", ticket_assignment: null,
initial_submit: [{ kind: "feature_invoke", invocation }], workdir_attachments: [], feature_connections: {}, control_operation_id: null };
assertEquals(parseCreateWorkspaceWorkerRequest(request).initial_submit, request.initial_submit);
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, arguments: [{ name: "count", value: { kind: "integer", value: 9007199254740992 } }],
} }] }), Error, "safe integer");
assertThrows(() => parseCreateWorkspaceWorkerRequest({ ...request, initial_submit: [{ kind: "feature_invoke", invocation: {
...invocation, extra: true,
} }] }), Error, "unknown field extra");
});
@@ -827,6 +827,45 @@ function uploadedFile(
};
}
function invocationValue(
value: unknown,
label: string,
): Extract<Extract<Segment, { kind: "feature_invoke" }>["invocation"]["arguments"][number], { name: string }>["value"] {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`);
exact(item, ["kind", "value"], label);
if (kind === "string") return { kind, value: string(item.value, `${label}.value`) };
if (kind === "integer") {
const parsed = number(item.value, `${label}.value`);
if (!Number.isSafeInteger(parsed)) throw new Error(`${label}.value must be a safe integer`);
if (parsed < -2147483648 || parsed > 2147483647) throw new Error(`${label}.value must be a signed 32-bit integer`);
return { kind, value: parsed };
}
if (kind === "boolean") return { kind, value: boolean(item.value, `${label}.value`) };
throw new Error(`${label}.kind is invalid`);
}
function featureInvocation(
value: unknown,
label: string,
): Extract<Segment, { kind: "feature_invoke" }>["invocation"] {
const item = record(value, label);
exact(item, ["invocation_id", "identity", "name", "arguments"], label);
return {
invocation_id: string(item.invocation_id, `${label}.invocation_id`),
identity: string(item.identity, `${label}.identity`),
name: string(item.name, `${label}.name`),
arguments: array(item.arguments, `${label}.arguments`, (argument, argumentLabel) => {
const entry = record(argument, argumentLabel);
exact(entry, ["name", "value"], argumentLabel);
return {
name: string(entry.name, `${argumentLabel}.name`),
value: invocationValue(entry.value, `${argumentLabel}.value`),
};
}),
};
}
function segment(value: unknown, label: string): Segment {
const item = record(value, label);
const kind = string(item.kind, `${label}.kind`) as Segment["kind"];
@@ -855,6 +894,12 @@ function segment(value: unknown, label: string): Segment {
case "file_ref":
exact(item, ["kind", "path"], label);
return { kind, path: string(item.path, `${label}.path`) };
case "feature_invoke":
exact(item, ["kind", "invocation"], label);
return {
kind,
invocation: featureInvocation(item.invocation, `${label}.invocation`),
};
case "flow":
exact(item, ["kind", "selector"], label);
return { kind, selector: string(item.selector, `${label}.selector`) };
@@ -2,6 +2,8 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import type { FeatureInvocationDescriptor, Segment } from "#lib/generated/protocol.ts";
import { undo } from "@codemirror/commands";
import ComposerInput from "./ComposerInput.svelte";
afterEach(cleanup);
@@ -148,3 +150,250 @@ test("cursor changes, Escape, scope changes and disable discard late file result
await ui.rerender({ disabled: true });
expect(ui.queryByRole("listbox")).toBeNull();
});
test("synchronous draft and cursor ABA invalidate old Feature completion generations", async () => {
const requests: Array<ReturnType<typeof deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>>> = [];
const resolver = vi.fn(() => { const result = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>(); requests.push(result); return result.promise; });
const ui = render(ComposerInput, { historyScope: "feature-aba", resolveFeatureCompletions: resolver });
const cm = editor(ui.container);
type(cm, "/ru");
await waitFor(() => expect(requests).toHaveLength(1));
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
requests[0].resolve([{ value: "stale", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(2));
expect(ui.queryByRole("option")).toBeNull();
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: 3 } });
requests[1].resolve([{ value: "stale-cursor", invocation: invocationDescriptor }]);
await waitFor(() => expect(requests).toHaveLength(3));
expect(ui.queryByRole("option")).toBeNull();
requests[2].resolve([{ value: "run", invocation: invocationDescriptor }]);
expect((await ui.findByRole("option")).textContent).toContain("/run");
});
test("restored chip descriptor edits reject same-draft cursor ABA", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "edit-cursor-aba", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
cm.dispatch({ selection: { anchor: 0 } });
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([invocationSegment]);
});
test("cancelled upload cannot become sendable via late completion or Undo", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "upload-cancel-late", onremoveatom });
const cm = editor(ui.container);
type(cm, "before after", 7);
const reservation = ui.component.reserveUpload("report.md")!;
ui.component.cancelUpload(reservation);
expect(ui.component.completeUpload(reservation, uploadedSegment)).toBe(false);
undo(cm);
expect(ui.component.snapshot().segments.some((segment) => segment.kind === "uploaded_file" || segment.kind === "unknown")).toBe(false);
ui.component.clear();
expect(onremoveatom).not.toHaveBeenCalled();
});
const invocationDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run",
description: "Declared invocation", syntax: "parenthesized",
arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
const invocationSegment: Segment = {
kind: "feature_invoke", invocation: { invocation_id: "invoke-1",
identity: invocationDescriptor.identity, name: "run",
arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] },
};
const uploadedSegment: Segment = { kind: "uploaded_file", file: {
artifact_id: "artifact-1", file_name: "report.md", media_type: "text/markdown",
created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd",
} };
test("argument popup stays anchored to the edited outer call when its data contains a call-looking slash", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const resolveFeatureArgumentCompletions = vi.fn(async () => [{ value: "completed" }]);
const ui = render(ComposerInput, { historyScope: "quoted-data-argument", resolveFeatureArgumentCompletions,
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }] });
const segment: Segment = { kind: "feature_invoke", invocation: { ...invocationSegment.invocation, arguments: [
{ name: "path", value: { kind: "string", value: "a /run(pa" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } };
ui.component.restoreSegments([segment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
const cm = editor(ui.container);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a /run(pa", mode="safe")'));
cm.dispatch({ selection: { anchor: cm.state.doc.toString().lastIndexOf("(pa") + 3 } });
await waitFor(() => expect(resolveFeatureArgumentCompletions).toHaveBeenLastCalledWith(
{ invocation: descriptor.identity, argument: "path" }, "a /run(pa", expect.any(AbortSignal),
));
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode="safe")');
});
test("mid-cursor completion of an incomplete quote preserves following arguments and prose", async () => {
const descriptor: FeatureInvocationDescriptor = { ...invocationDescriptor, arguments: [...invocationDescriptor.arguments,
{ name: "mode", required: false, value_type: { kind: "enum", values: ["safe"] }, completion: { kind: "static", values: ["safe"] } }] };
const ui = render(ComposerInput, { historyScope: "incomplete-quote-suffix",
resolveFeatureCompletions: async () => [{ value: "run", invocation: descriptor }],
resolveFeatureArgumentCompletions: async () => [{ value: "completed" }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const value = '/run(path="ab, mode=safe) following prose';
cm.dispatch({ changes: { from: cm.state.doc.length, insert: value.slice(5) }, selection: { anchor: value.indexOf("ab") + 2 } });
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(cm.state.doc.toString()).toBe('/run(path="completed", mode=safe) following prose');
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "completed" } },
{ name: "mode", value: { kind: "string", value: "safe" } },
] } });
expect(snapshot.segments[1]).toEqual({ kind: "text", content: " following prose" });
});
test("selected malformed surrogate strings stay editable and reject Submit snapshot", async () => {
const ui = render(ComposerInput, { historyScope: "invalid-surrogate-submit",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
const invalid = String.raw`"\uD800")`;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: invalid }, selection: { anchor: 5 + invalid.length } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
expect(() => ui.component.snapshot()).toThrow("unpaired Unicode surrogates");
const valid = String.raw`"\uD83D\uDE00")`;
cm.dispatch({ changes: { from: 5, to: cm.state.doc.length, insert: valid }, selection: { anchor: 5 + valid.length } });
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0]).toMatchObject({ kind: "feature_invoke", invocation: { arguments: [
{ name: "path", value: { kind: "string", value: "😀" } },
] } });
});
test("selected calls chipify without promoting matching typed or pasted text", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-select",
resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
cm.dispatch({ changes: { from: cm.state.doc.length, insert: ' /run("literal")' } });
await Promise.resolve();
const snapshot = ui.component.snapshot();
expect(snapshot.segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
expect(snapshot.segments[1]).toEqual({ kind: "text", content: ' /run("literal")' });
expect(snapshot.segments[0].kind === "feature_invoke" && snapshot.segments[0].invocation.arguments[0].value)
.toEqual({ kind: "string", value: "a b/資料" });
});
test("restored invocation chips fetch declarative descriptors and stay editable inside arguments", async () => {
const resolveFeatureCompletions = vi.fn(async () => [{ value: "run", invocation: invocationDescriptor }]);
const ui = render(ComposerInput, { historyScope: "invocation-edit", resolveFeatureCompletions });
ui.component.restoreSegments([invocationSegment]);
const cm = editor(ui.container);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(cm.state.doc.toString()).toBe('/run(path="a b/資料")'));
expect(resolveFeatureCompletions).toHaveBeenCalledWith("run", expect.any(AbortSignal));
cm.dispatch({ changes: { from: 11, to: 12, insert: "c" }, selection: { anchor: 12 } });
await Promise.resolve();
expect(ui.container.querySelector(".composer-typed-chip")).toBeNull();
const snapshot = ui.component.snapshot();
expect(snapshot.segments[0].kind).toBe("feature_invoke");
});
test("attachment adapter uses descriptor capability rather than a hardcoded slash name", async () => {
const onclientadapter = vi.fn();
const adapter = { ...invocationDescriptor, name: "send-file", arguments: [], client_adapter: "attachment" as const };
const ui = render(ComposerInput, { historyScope: "invocation-adapter", onclientadapter,
resolveFeatureCompletions: async () => [{ value: adapter.name, invocation: adapter }] });
const cm = editor(ui.container);
type(cm, "before /send");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
expect(onclientadapter).toHaveBeenCalledWith(adapter);
expect(cm.state.doc.toString()).toBe("before ");
});
test("uploads preserve reserved order, deletion undo, cleanup and accepted ownership", async () => {
const onremoveatom = vi.fn();
const ui = render(ComposerInput, { historyScope: "invocation-upload", onremoveatom });
const cm = editor(ui.container);
type(cm, "before ");
const first = ui.component.reserveUpload("report.md")!;
const second = ui.component.reserveUpload("second.md")!;
cm.dispatch({ changes: { from: cm.state.doc.length, insert: " after" } });
const secondSegment = { ...uploadedSegment, file: { ...uploadedSegment.file, artifact_id: "artifact-2", file_name: "second.md" } };
expect(ui.component.completeUpload(second, secondSegment)).toBe(true);
expect(ui.component.completeUpload(first, uploadedSegment)).toBe(true);
expect(ui.component.snapshot().segments).toEqual([
{ kind: "text", content: "before " }, uploadedSegment, secondSegment, { kind: "text", content: " after" },
]);
const tokenEnd = "before ".length + 3;
cm.dispatch({ selection: { anchor: tokenEnd } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(onremoveatom).not.toHaveBeenCalled();
undo(cm);
expect(ui.component.snapshot().segments).toContainEqual(uploadedSegment);
ui.component.clear(true);
expect(onremoveatom).not.toHaveBeenCalled();
expect(undo(cm)).toBe(false);
ui.component.insertSegment(uploadedSegment, true);
ui.component.restoreSegments([invocationSegment]);
expect(onremoveatom).toHaveBeenCalledWith(uploadedSegment);
});
test("restored opaque segments and typed clipboard labels are not lost", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-restore" });
const segments: Segment[] = [invocationSegment, uploadedSegment, { kind: "file_ref", path: "a b/c" }, { kind: "flow", selector: "legacy" }];
ui.component.restoreSegments(segments);
expect(ui.component.snapshot().segments).toEqual(segments);
const cm = editor(ui.container);
cm.dispatch({ selection: { anchor: 0, head: cm.state.doc.length } });
const setData = vi.fn();
await fireEvent.copy(cm.contentDOM, { clipboardData: { setData } });
expect(setData).toHaveBeenCalledWith("text/plain", expect.stringContaining('/run(path="a b/資料")'));
expect(setData.mock.calls[0][1]).not.toContain("\uFFF8");
});
test("restored descriptor requests cannot edit a newer draft after a scope change", async () => {
const request = deferred<Array<{ value: string; invocation: FeatureInvocationDescriptor }>>();
const resolver = vi.fn(() => request.promise);
const ui = render(ComposerInput, { historyScope: "invocation-stale-edit", completionScope: "worker-a", resolveFeatureCompletions: resolver });
ui.component.restoreSegments([invocationSegment]);
await fireEvent.mouseDown(ui.container.querySelector(".composer-typed-chip")!);
await waitFor(() => expect(resolver).toHaveBeenCalledOnce());
await ui.rerender({ completionScope: "worker-b" });
ui.component.restoreSegments([{ kind: "text", content: "new draft" }]);
request.resolve([{ value: "run", invocation: invocationDescriptor }]);
await Promise.resolve();
await Promise.resolve();
expect(ui.component.snapshot().segments).toEqual([{ kind: "text", content: "new draft" }]);
});
test("pasted invocation text remains literal after descriptor selection", async () => {
const ui = render(ComposerInput, { historyScope: "invocation-paste-literal", resolveFeatureCompletions: async () => [{ value: "run", invocation: invocationDescriptor }] });
const cm = editor(ui.container);
type(cm, "/ru");
await ui.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"x")' }, selection: { anchor: '/run("x")'.length } });
await waitFor(() => expect(ui.container.querySelectorAll(".composer-typed-chip")).toHaveLength(1));
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => ' /run("pasted")', items: [] } });
expect(ui.component.snapshot().segments.map((segment) => segment.kind)).toEqual(["feature_invoke", "text"]);
});
@@ -6,6 +6,7 @@
Prec,
StateEffect,
StateField,
type Extension,
} from "@codemirror/state";
import {
Decoration,
@@ -22,7 +23,11 @@
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "#lib/generated/protocol.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
Segment,
} from "#lib/generated/protocol.ts";
import {
handleComposerImagePaste,
measureComposerPaste,
@@ -32,11 +37,14 @@
composerDeletionRange,
composerPasteAtoms,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
type ComposerTextPaste,
type ComposerTypedSegment,
} from "#lib/workspace/console/composer-draft.ts";
import {
ComposerHistory,
@@ -48,6 +56,10 @@
} from "#lib/workspace/console/composer-history.ts";
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
import { shouldSubmitChatKey } from "#lib/workspace/console/chat-submit.ts";
import {
invocationInput,
selectedFeatureInvocationAt,
} from "#lib/workspace/console/composer-invocation.ts";
interface Props {
disabled?: boolean;
@@ -61,6 +73,14 @@
oncommand?: () => void;
completionScope?: string;
resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
resolveFeatureArgumentCompletions?: (
context: CompletionContext,
prefix: string,
signal: AbortSignal,
) => Promise<ComposerCompletionEntry[]>;
onremoveatom?: (segment: Segment) => void;
onclientadapter?: (descriptor: FeatureInvocationDescriptor) => void;
}
let {
@@ -75,14 +95,45 @@
oncommand,
completionScope = "",
resolveFileCompletions,
resolveFeatureCompletions,
resolveFeatureArgumentCompletions,
onremoveatom,
onclientadapter,
}: Props = $props();
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let editorExtensions: Extension[] = [];
const stagedTypedAtoms = new Map<number, Segment>();
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1;
let nextPasteKey = 1;
let nextTypedKey = 1;
const selectedFeatureInvocations = new Map<string, FeatureInvocationDescriptor>();
type SelectedInvocation = { from: number; descriptor: FeatureInvocationDescriptor };
const selectInvocation = StateEffect.define<SelectedInvocation>();
const restoreSelectedInvocations = StateEffect.define<readonly SelectedInvocation[]>();
const selectedInvocationState = StateField.define<readonly SelectedInvocation[]>({
create: () => [],
update(selected, transaction) {
const restored = transaction.effects.find((effect) => effect.is(restoreSelectedInvocations));
if (restored) return restored.value;
const document = transaction.newDoc.toString();
const retained = selected.map((item) => ({
...item,
from: transaction.changes.mapPos(item.from, 1),
})).filter((item) =>
[item.descriptor.name, ...item.descriptor.aliases].some((name) =>
document.startsWith(`/${name}(`, item.from)
)
);
for (const effect of transaction.effects) {
if (effect.is(selectInvocation)) retained.push(effect.value);
}
return retained;
},
});
const editable = new Compartment();
const completionId = $props.id();
@@ -117,7 +168,12 @@
completionContext = context;
closeCompletion();
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
const token = completionTokenAt(document, selection.head);
const token = completionTokenAt(
document,
selection.head,
[...selectedFeatureInvocations.values()],
view.state.field(selectedInvocationState),
);
if (!token) return;
completionToken = token;
if (token.kind === "command") {
@@ -125,16 +181,26 @@
if (!completionEntries.length) completionMessage = "No matching commands";
return;
}
if (!resolveFileCompletions) { closeCompletion(); return; }
const resolver = token.kind === "file"
? resolveFileCompletions && ((signal: AbortSignal) =>
resolveFileCompletions(token.prefix, signal))
: token.kind === "feature"
? resolveFeatureCompletions && ((signal: AbortSignal) =>
resolveFeatureCompletions(token.prefix, signal))
: token.context && resolveFeatureArgumentCompletions
? ((signal: AbortSignal) =>
resolveFeatureArgumentCompletions(token.context!, token.prefix, signal))
: null;
if (!resolver) { closeCompletion(); return; }
completionBusy = true;
const version = completionVersion;
const abort = new AbortController();
completionAbort = abort;
void resolveFileCompletions(token.prefix, abort.signal).then((entries) => {
void resolver(abort.signal).then((entries) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionEntries = entries;
completionSelected = entries.length ? 0 : null;
if (!entries.length) completionMessage = "No matching files";
if (!entries.length) completionMessage = `No matching ${token.kind.replace("_", " ")}s`;
}).catch((error) => {
if (version !== completionVersion || abort.signal.aborted) return;
completionMessage = error instanceof Error ? error.message : String(error);
@@ -158,11 +224,27 @@
}
const entry = index === null ? undefined : completionEntries[index];
if (!entry) return;
if (token.kind === "feature" && entry.invocation) {
selectedFeatureInvocations.set(entry.invocation.identity, entry.invocation);
if (entry.invocation.client_adapter === "attachment") {
closeCompletion();
view.dispatch({
changes: { from: token.start, to: token.end, insert: "" },
selection: EditorSelection.cursor(token.start),
userEvent: "input.complete",
});
onclientadapter?.(entry.invocation);
return;
}
}
const result = applyCompletion(document, token, entry, action);
const to = document.length - (result.value.length - result.cursor);
closeCompletion();
view.dispatch({
changes: { from: token.start, to, insert: result.value.slice(token.start, result.cursor) },
effects: token.kind === "feature" && entry.invocation
? selectInvocation.of({ from: token.start, descriptor: entry.invocation })
: [],
selection: EditorSelection.cursor(result.cursor), userEvent: "input.complete",
});
view.focus();
@@ -209,6 +291,12 @@
$effect(() => {
void completionScope;
selectedFeatureInvocations.clear();
closeCompletion();
completionContext = "";
});
$effect(() => {
void disabled;
closeCompletion();
completionContext = "";
@@ -315,6 +403,171 @@
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
const registerTypedAtom = StateEffect.define<{
key: number;
atom: ComposerTypedSegment;
}>();
const typedRegistry = StateField.define<ReadonlyMap<number, ComposerTypedSegment>>({
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerTypedAtom));
if (!additions.length) return registry;
const next = new Map(registry);
for (const addition of additions) next.set(addition.value.key, addition.value.atom);
return next;
},
});
class TypedChipWidget extends WidgetType {
readonly atom: ComposerTypedSegment;
constructor(atom: ComposerTypedSegment) {
super();
this.atom = atom;
}
override eq(other: TypedChipWidget): boolean {
return JSON.stringify(other.atom.segment) === JSON.stringify(this.atom.segment) &&
other.atom.label === this.atom.label;
}
override toDOM(): HTMLElement {
const chip = document.createElement("span");
chip.className = "composer-typed-chip";
chip.textContent = this.atom.label;
chip.title = this.atom.label;
chip.setAttribute("role", "note");
chip.setAttribute("aria-label", this.atom.label);
return chip;
}
override ignoreEvent(): boolean { return false; }
}
function typedDecorations(state: EditorState): DecorationSet {
return Decoration.set(
composerTypedAtoms(state.doc.toString(), state.field(typedRegistry)).map((atom) =>
Decoration.replace({
widget: new TypedChipWidget(atom),
inclusive: false,
}).range(atom.from, atom.to)
),
true,
);
}
const typedChips = [
typedRegistry,
EditorView.decorations.of((currentView) => typedDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => typedDecorations(currentView.state)),
];
// Deleted uploads remain staged while Undo can still restore their atoms.
// Release abandoned resources at a draft boundary; accepted live atoms belong to the host.
function releaseStagedAtoms(accepted = false): void {
const live = accepted && view ? new Set(composerTypedAtoms(
view.state.doc.toString(), view.state.field(typedRegistry),
).map((atom) => JSON.stringify(atom.segment))) : new Set<string>();
for (const segment of stagedTypedAtoms.values()) {
if (!live.has(JSON.stringify(segment))) onremoveatom?.(segment);
}
stagedTypedAtoms.clear();
}
let suppressInvocationChipify = false;
function typedAtomLabel(segment: Segment): string {
if (segment.kind === "feature_invoke") return invocationInput(segment.invocation);
if (segment.kind === "uploaded_file") return `Attached ${segment.file.file_name}`;
if (segment.kind === "file_ref") return `@${segment.path}`;
if (segment.kind === "paste_artifact") return "Restored paste";
if (segment.kind === "flow") return `Flow ${segment.selector}`;
return "Structured input";
}
function insertTypedSegment(
segment: Segment,
options: { from?: number; to?: number; cleanup?: boolean; label?: string } = {},
): number | null {
if (!view || view.state.readOnly) return null;
const selection = view.state.selection.main;
const from = options.from ?? selection.from;
const to = options.to ?? selection.to;
const key = nextTypedKey++;
if (options.cleanup) stagedTypedAtoms.set(key, segment);
const token = composerTypedToken(key);
view.dispatch({
changes: { from, to, insert: token },
selection: EditorSelection.cursor(from + token.length),
effects: registerTypedAtom.of({
key,
atom: { segment, label: options.label ?? typedAtomLabel(segment), cleanup: options.cleanup },
}),
annotations: isolateHistory.of("full"),
userEvent: "input.complete",
});
return key;
}
function chipifySelectedInvocation(strict = false): void {
if (!view || view.composing || view.state.readOnly) return;
// Only completion-selected or explicitly edited calls become structured input.
// Descriptor discovery must never promote unrelated typed or pasted prose.
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
try {
const range = selectedFeatureInvocationAt(
view.state.doc.toString(), selected.from, selected.descriptor,
);
if (!range) {
if (strict) throw new Error("Finish or remove the selected Feature invocation before sending.");
continue;
}
const head = view.state.selection.main.head;
if (!strict && head > range.start && head < range.end) continue;
insertTypedSegment(
{ kind: "feature_invoke", invocation: range.invocation },
{ from: range.start, to: range.end },
);
} catch (error) {
// Invalid calls stay editable until Submit reports their validation error.
if (strict) throw error;
}
}
}
let editVersion = 0;
let editorGeneration = 0;
async function editInvocation(key: number): Promise<void> {
if (!view || disabled) return;
const scope = completionScope;
const version = ++editVersion;
const document = view.state.doc.toString();
const selection = view.state.selection.main;
const generation = editorGeneration;
let atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
const invocation = atom.segment.invocation;
let descriptor = selectedFeatureInvocations.get(invocation.identity);
if (!descriptor && resolveFeatureCompletions) {
try {
const entries = await resolveFeatureCompletions(invocation.name, new AbortController().signal);
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
} catch { return; }
}
if (!descriptor || !view || disabled || version !== editVersion || scope !== completionScope ||
generation !== editorGeneration || document !== view.state.doc.toString() ||
selection.from !== view.state.selection.main.from || selection.to !== view.state.selection.main.to) return;
atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
if (atom?.segment.kind !== "feature_invoke") return;
selectedFeatureInvocations.set(descriptor.identity, descriptor);
const input = invocationInput(invocation);
suppressInvocationChipify = true;
view.dispatch({
changes: { from: atom.from, to: atom.to, insert: input },
selection: EditorSelection.cursor(atom.from + input.length - 1),
effects: selectInvocation.of({ from: atom.from, descriptor }),
annotations: isolateHistory.of("full"),
userEvent: "input",
});
view.focus();
}
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [], textPastes: [] };
@@ -323,6 +576,7 @@
state.doc.toString(),
state.field(pasteRegistry),
state.field(textPasteState),
state.field(typedRegistry),
);
}
@@ -339,6 +593,7 @@
}
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
if ([...currentView.state.field(typedRegistry).values()].some((atom) => atom.segment.kind === "unknown")) return false;
const selection = currentView.state.selection.main;
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
if (!shouldBrowseComposerHistory({
@@ -435,20 +690,26 @@
from: paste.from - selection.from,
to: paste.to - selection.from,
}));
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes).content;
return snapshotComposerDraft(document, selectedRegistry, selectedTextPastes, state.field(typedRegistry)).content;
}
function deleteAdjacentPasteFromView(
function deleteAdjacentAtomFromView(
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
if (currentView.state.readOnly) return false;
const selection = currentView.state.selection.main;
const pastes = composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
);
const deletion = composerDeletionRange(selection, pastes, direction);
const atoms = [
...composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
),
...composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
),
];
const deletion = composerDeletionRange(selection, atoms, direction);
if (!deletion) return false;
currentView.dispatch({
changes: deletion,
@@ -467,7 +728,7 @@
view = new EditorView({
parent: mountElement,
state: EditorState.create({
extensions: [
extensions: editorExtensions = [
history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(keymap.of([
@@ -494,16 +755,21 @@
{
key: "Backspace",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "backward"),
deleteAdjacentAtomFromView(currentView, "backward"),
},
{
key: "Delete",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "forward"),
deleteAdjacentAtomFromView(currentView, "forward"),
},
])),
keymap.of([...defaultKeymap, ...historyKeymap]),
selectedInvocationState,
invertedEffects.of((transaction) => transaction.docChanged
? [restoreSelectedInvocations.of(transaction.startState.field(selectedInvocationState))]
: []),
pasteChips,
typedChips,
editable.of([
EditorView.editable.of(!disabled),
EditorState.readOnly.of(disabled),
@@ -518,7 +784,20 @@
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion());
if (update.docChanged || update.selectionSet) {
editorGeneration++;
// Invalidate synchronously: an A→B→A edit/cursor cycle in one
// microtask must not revive an earlier completion generation.
closeCompletion();
completionContext = "";
queueMicrotask(() => refreshCompletion());
}
if (update.docChanged) {
if (suppressInvocationChipify) suppressInvocationChipify = false;
else if (!update.transactions.some((transaction) => transaction.isUserEvent("undo") || transaction.isUserEvent("redo"))) {
queueMicrotask(chipifySelectedInvocation);
}
}
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
@@ -528,6 +807,26 @@
focus() { queueMicrotask(() => refreshCompletion(true)); return false; },
blur() { closeCompletion(); return false; },
compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; },
mousedown(event, currentView) {
const target = event.target instanceof Element
? event.target.closest(".composer-typed-chip")
: null;
if (!target || currentView.state.readOnly) return false;
let position: number;
try {
position = currentView.posAtDOM(target);
} catch {
return false;
}
const atom = composerTypedAtoms(
currentView.state.doc.toString(),
currentView.state.field(typedRegistry),
).find((candidate) => candidate.from <= position && position <= candidate.to);
if (atom?.segment.kind !== "feature_invoke") return false;
event.preventDefault();
void editInvocation(atom.key);
return true;
},
paste(event) {
return handlePasteEvent(event);
},
@@ -582,6 +881,8 @@
return () => {
closeCompletion();
releaseStagedAtoms();
editVersion++;
view?.destroy();
view = null;
};
@@ -598,12 +899,14 @@
});
export function snapshot(): ComposerDraftSnapshot {
chipifySelectedInvocation(true);
return currentSnapshot();
}
export function recordHistory(value: ComposerDraftSnapshot): void {
const entry: ComposerHistoryEntry = {
segments: value.segments,
// Uploaded references are Worker-owned and must not leak through workspace-wide history.
segments: value.segments.filter((segment) => segment.kind !== "uploaded_file"),
preserveExactText: value.textPastes.length > 0,
};
if (composerHistory.record(entry)) {
@@ -632,23 +935,61 @@
});
}
export function clear(): void {
export function insertSegment(segment: Segment, cleanup = false): boolean {
return insertTypedSegment(segment, { cleanup }) !== null;
}
export function reserveUpload(fileName: string): number | null {
return insertTypedSegment({ kind: "unknown" }, { label: `Uploading ${fileName}` });
}
export function completeUpload(key: number, segment: Segment): boolean {
if (!view || view.state.field(typedRegistry).get(key)?.segment.kind !== "unknown") return false;
stagedTypedAtoms.set(key, segment);
view.dispatch({ effects: registerTypedAtom.of({
key, atom: { segment, label: typedAtomLabel(segment), cleanup: true },
}) });
return true;
}
export function cancelUpload(key: number): void {
if (!view) return;
const atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
.find((candidate) => candidate.key === key);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
selection: EditorSelection.cursor(0),
userEvent: "input",
changes: atom ? { from: atom.from, to: atom.to, insert: "" } : [],
// Undo cannot resurrect a cancelled upload as a sendable placeholder.
effects: registerTypedAtom.of({ key, atom: {
segment: { kind: "text", content: "" }, label: "Removed upload",
} }),
userEvent: "delete",
});
}
export function clear(acceptedTypedResources = false, retainStagedResources = false): void {
if (!view) return;
if (!retainStagedResources) releaseStagedAtoms(acceptedTypedResources);
editVersion++;
closeCompletion();
completionContext = "";
view.setState(EditorState.create({ extensions: editorExtensions }));
view.dispatch({ effects: editable.reconfigure([
EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled),
]) });
emitChange();
}
export function restoreSegments(
segments: readonly Segment[],
preserveExactText = false,
ownStagedUploads = false,
): void {
if (!view) return;
clear(false, restoringHistory);
let document = "";
const pasteEffects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
const textEffects: StateEffect<ComposerTextPaste>[] = [];
const typedEffects: StateEffect<{ key: number; atom: ComposerTypedSegment }>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
@@ -674,15 +1015,22 @@
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
pasteEffects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
} else {
const key = nextTypedKey++;
document += composerTypedToken(key);
const cleanup = ownStagedUploads && segment.kind === "uploaded_file";
if (cleanup) stagedTypedAtoms.set(key, segment);
typedEffects.push(registerTypedAtom.of({
key,
atom: { segment, label: typedAtomLabel(segment), cleanup },
}));
}
}
nextPasteId = highestPasteId + 1;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects: [...pasteEffects, ...textEffects],
effects: [...pasteEffects, ...textEffects, ...typedEffects],
userEvent: "input.restore",
});
}
@@ -788,7 +1136,8 @@
padding: 0;
}
.composer-input :global(.composer-paste-chip) {
.composer-input :global(.composer-paste-chip),
.composer-input :global(.composer-typed-chip) {
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
@@ -1,3 +1,4 @@
import { invocationInput } from "./composer-invocation.ts";
import type { Segment } from "#lib/generated/protocol.ts";
export type WorkerConsoleInputKind =
@@ -88,9 +89,10 @@ export function buildComposerSegmentsRequest(
sourceSegments: readonly Segment[],
options: ComposerSegmentsRequestOptions = {},
): ComposerCommandResult {
const hasRichSegment = sourceSegments.some((segment) =>
segment.kind === "paste" || segment.kind === "uploaded_file"
);
if (sourceSegments.some((segment) => segment.kind === "unknown")) {
return { ok: false, message: "Finish or remove incomplete structured input before sending." };
}
const hasRichSegment = sourceSegments.some((segment) => segment.kind !== "text");
if (!hasRichSegment) {
const content = sourceSegments.map(segmentContent).join("");
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
@@ -154,8 +156,10 @@ function segmentContent(segment: Segment): string {
return `@${segment.path}`;
case "flow":
return segment.selector;
case "feature_invoke":
return invocationInput(segment.invocation);
case "paste_artifact":
return "";
return "[Restored paste]";
case "uploaded_file":
return `[Attached file: ${segment.file.file_name}]`;
default:
@@ -23,7 +23,7 @@ function assertEquals<T>(actual: T, expected: T): void {
}
}
Deno.test("completionTokenAt detects command and file sigils before the cursor", () => {
Deno.test("completionTokenAt detects command, file, and Feature sigils before the cursor", () => {
assertEquals(completionTokenAt("open @src/ma", "open @src/ma".length), {
sigil: "@",
kind: "file",
@@ -32,7 +32,13 @@ Deno.test("completionTokenAt detects command and file sigils before the cursor",
prefix: "src/ma",
});
assertEquals(completionTokenAt(":comp", 5)?.kind, "command");
assertEquals(completionTokenAt("run /work", 9), null);
assertEquals(completionTokenAt("run /work", 9), {
sigil: "/",
kind: "feature",
start: 4,
end: 9,
prefix: "work",
});
assertEquals(completionTokenAt("ask #plain", "ask #plain".length), null);
});
@@ -92,3 +98,59 @@ Deno.test("localCommandCompletions filters colon commands", () => {
"compact",
]);
});
Deno.test("argument completions replace whole quoted values and preserve following arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run("src/ma', '/run("src/ma", next=true)', '/run(path="src/ma", next=true)']) {
const cursor = value.indexOf("ma") + 1;
const token = completionTokenAt(value, cursor, [descriptor]);
assert(token, "argument token should exist");
assertEquals(token.prefix, "src/m");
assertEquals(applyCompletion(value, token, { value: '資料/a "b"/c' }).value,
value.startsWith('/run(path') ? '/run(path="資料/a \\"b\\"/c", next=true)' :
value.endsWith('true)') ? '/run("資料/a \\"b\\"/c", next=true)' : '/run("資料/a \\"b\\"/c"a');
}
});
Deno.test("incomplete or invalid quoted completion preserves mode and body after the cursor", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
for (const value of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = value.indexOf("ab") + 2;
const token = completionTokenAt(value, cursor, [descriptor], [{ from: 0, descriptor }]);
assert(token, "selected argument completion should exist");
assertEquals(token.end, cursor);
const result = applyCompletion(value, token, { value: "completed/資料" });
assertEquals(result.value.slice(result.cursor), value.slice(cursor));
assert(result.value.includes("mode=safe) following prose"), "other arguments and body must remain intact");
}
});
Deno.test("quoted call-looking data completes the selected outer value without erasing other arguments", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const value = '/run("a /run(pa", mode=safe) prose';
const token = completionTokenAt(value, value.lastIndexOf("pa") + 2, [descriptor], [{ from: 0, descriptor }]);
assert(token, "outer argument completion should exist");
assertEquals(token.context?.argument, "path");
assertEquals(token.prefix, "a /run(pa");
assertEquals(applyCompletion(value, token, { value: "replacement" }).value, '/run("replacement", mode=safe) prose');
});
Deno.test("argument name replacement preserves its value and directories drill inside quotes", () => {
const descriptor = { identity: "test/run", name: "run", aliases: [], display_name: "Run", description: "Test",
syntax: "parenthesized" as const, arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" as const }, completion: { kind: "worker_file" as const } }] };
const named = '/run(path="original")';
const token = completionTokenAt(named, 7, [descriptor]);
assert(token, "named completion should exist");
assertEquals(applyCompletion(named, token, { value: "path=" }).value, named);
const directory = '/run("sr")';
const result = applyCompletion(directory, completionTokenAt(directory, 8, [descriptor])!, { value: "src", is_dir: true });
assertEquals(result.value, '/run("src/")');
assertEquals(completionTokenAt(result.value, result.cursor, [descriptor])?.prefix, "src/");
});
@@ -1,18 +1,25 @@
import { COMMANDS } from "./composer-command.ts";
import type {
CompletionContext,
FeatureInvocationDescriptor,
} from "#lib/generated/protocol.ts";
import { activeInvocationArgument, type SelectedInvocationOccurrence } from "./composer-invocation.ts";
export type ComposerCompletionKind = "command" | "file";
export type ComposerCompletionKind = "command" | "file" | "feature" | "feature_argument";
export type ComposerCompletionToken = {
sigil: ":" | "@";
sigil: ":" | "@" | "/" | "";
kind: ComposerCompletionKind;
start: number;
end: number;
prefix: string;
context?: CompletionContext;
};
export type ComposerCompletionEntry = {
value: string;
is_dir?: boolean;
description?: string;
usage?: string;
description?: string | null;
usage?: string | null;
invocation?: FeatureInvocationDescriptor | null;
};
export type CompletionApplyResult = { value: string; cursor: number };
const ALIASES: Record<string, string> = { "?": "help", rollback: "rewind" };
@@ -28,9 +35,40 @@ export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object
export function completionTokenAt(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[] = [],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ComposerCompletionToken | null {
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
const activeArgument = activeInvocationArgument(value, boundedCursor, descriptors, selectedOccurrences);
if (activeArgument) {
return {
sigil: "",
kind: "feature_argument",
start: activeArgument.start,
end: activeArgument.end,
prefix: activeArgument.prefix,
context: {
invocation: activeArgument.descriptor.identity,
argument: activeArgument.argument?.name,
},
};
}
const before = value.slice(0, boundedCursor);
const feature = /(^|\s)\/([a-z0-9_-]*)$/.exec(before);
if (feature) {
const prefix = feature[2] ?? "";
const start = before.length - prefix.length - 1;
const tail = /^[a-z0-9_-]*/.exec(value.slice(boundedCursor))?.[0] ?? "";
return {
sigil: "/",
kind: "feature",
start,
end: boundedCursor + tail.length,
prefix,
};
}
const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before);
if (!match) return null;
const sigil = match[2] as ":" | "@";
@@ -78,19 +116,46 @@ export function applyCompletion(
entry: ComposerCompletionEntry,
action: "tab" | "accept" = "accept",
): CompletionApplyResult {
if (token.kind === "feature") {
const replacement = `/${entry.value}(`;
return replaceCompletionRange(value, token, replacement);
}
if (token.kind === "feature_argument") {
const argumentName = !token.context?.argument && entry.value.endsWith("=");
const completed = entry.is_dir && !entry.value.endsWith("/") ? `${entry.value}/` : entry.value;
const replacement = argumentName ? entry.value : JSON.stringify(completed);
const result = replaceCompletionRange(value,
argumentName && value[token.end] === "=" ? { ...token, end: token.end + 1 } : token,
replacement,
);
// Keep directory drilling inside the quoted value instead of after its closing quote.
if (entry.is_dir && !argumentName) result.cursor--;
return result;
}
const suffix = entry.is_dir
? (entry.value.endsWith("/") ? "" : "/")
: token.kind === "file" && action === "tab"
? ""
: " ";
const replacement = `${token.sigil}${entry.value}${suffix}`;
const restStart = suffix === " " && value[token.end] === " "
return replaceCompletionRange(
value,
token,
`${token.sigil}${entry.value}${suffix}`,
suffix === " ",
);
}
function replaceCompletionRange(
value: string,
token: ComposerCompletionToken,
replacement: string,
consumeSpace = false,
): CompletionApplyResult {
const restStart = consumeSpace && value[token.end] === " "
? token.end + 1
: token.end;
return {
value: `${value.slice(0, token.start)}${replacement}${
value.slice(restStart)
}`,
value: `${value.slice(0, token.start)}${replacement}${value.slice(restStart)}`,
cursor: token.start + replacement.length,
};
}
@@ -2,8 +2,12 @@ declare const Deno: {
test(name: string, fn: () => void): void;
};
import { deepStrictEqual, throws } from "node:assert/strict";
import type { Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
import {
canDeliverComposerDraft,
ComposerAdmissions,
sendComposerDelivery,
} from "./composer-delivery.ts";
@@ -13,6 +17,187 @@ function assertEquals(actual: unknown, expected: unknown): void {
}
}
const upload: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "staged-upload",
file_name: "資料.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const typedSnapshot: ComposerDraftSnapshot = {
document: "exact typed draft",
content: 'before /run(path="a b/資料") after',
pastes: [],
textPastes: [],
segments: [
{ kind: "text", content: "before " },
{
kind: "feature_invoke",
invocation: {
invocation_id: "stable-invocation",
identity: "feature:test/run",
name: "run",
arguments: [{
name: "path",
value: { kind: "string", value: "a b/資料" },
}],
},
},
upload,
{ kind: "text", content: " after" },
],
};
Deno.test("Submit admission keeps exact typed draft and stable IDs across rejection and retry", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "stable-request",
input: typedSnapshot.segments,
},
};
const first = admissions.begin("target-a", method, typedSnapshot, "submit");
deepStrictEqual(first.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
assertEquals(
admissions.acknowledge("target-b", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "started",
},
}),
null,
);
assertEquals(
admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "other-client",
message: "unrelated",
},
}),
null,
);
const rejection = admissions.acknowledge("target-a", {
event: "submission_rejected",
data: {
submission_request_id: "stable-request",
message: "validation failed",
},
});
assertEquals(rejection?.accepted, false);
deepStrictEqual(rejection?.record.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), false);
const retry = admissions.begin(
"target-a",
{
...method,
params: { ...method.params, submission_request_id: "do-not-use-new-id" },
},
typedSnapshot,
"submit",
);
deepStrictEqual(retry.method, first.method);
const accepted = admissions.acknowledge("target-a", {
event: "submission_accepted",
data: {
submission_request_id: "stable-request",
submission_id: "s",
disposition: "queued",
},
});
assertEquals(accepted?.accepted, true);
deepStrictEqual(accepted?.record.snapshot.segments, typedSnapshot.segments);
assertEquals(admissions.get("target-a"), null);
});
Deno.test("unknown admission protects uploads and in-flight retries from draft deletion and target ABA", () => {
const admissions = new ComposerAdmissions();
const method = {
method: "submit" as const,
params: {
submission_request_id: "unknown-request",
input: typedSnapshot.segments,
},
};
admissions.begin("target-a", method, typedSnapshot, "queue");
admissions.disconnected("target-a");
assertEquals(admissions.get("target-a")?.status, "unknown");
assertEquals(admissions.protects(upload), true);
throws(
() =>
admissions.begin(
"target-a",
method,
{ ...typedSnapshot, segments: [] },
"queue",
),
/in-flight/,
);
admissions.disconnected("target-b");
deepStrictEqual(admissions.retry("target-a")?.method, method);
deepStrictEqual(admissions.get("target-a")?.snapshot, typedSnapshot);
assertEquals(admissions.protects(upload), true);
});
Deno.test("Notify admission correlates authoritative rejection/success and retries unchanged IDs", () => {
const admissions = new ComposerAdmissions();
const snapshot = {
...typedSnapshot,
segments: [{ kind: "text" as const, content: "通知" }],
};
const method = {
method: "notify" as const,
params: { notification_request_id: "notification-1", message: "通知" },
};
admissions.begin("target", method, snapshot, "notify");
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: { notification_request_id: "other", message: "old" },
}),
null,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_rejected",
data: {
notification_request_id: "notification-1",
message: "not running",
},
})?.accepted,
false,
);
deepStrictEqual(
admissions.begin(
"target",
{
...method,
params: { ...method.params, notification_request_id: "new" },
},
snapshot,
"notify",
).method,
method,
);
assertEquals(
admissions.acknowledge("target", {
event: "notification_accepted",
data: { notification_request_id: "notification-1" },
})?.accepted,
true,
);
assertEquals(admissions.get("target"), null);
});
const base = {
protocolOpen: true,
sending: false,
@@ -1,3 +1,123 @@
import type { Event, Method, Segment } from "#lib/generated/protocol.ts";
import type { ComposerDraftSnapshot } from "./composer-draft.ts";
export type ComposerAdmissionMethod = Extract<
Method,
{ method: "submit" | "notify" }
>;
export type ComposerAdmission = {
method: ComposerAdmissionMethod;
snapshot: ComposerDraftSnapshot;
delivery: ComposerDelivery;
status: "waiting" | "unknown" | "rejected";
};
function admissionId(method: ComposerAdmissionMethod): string {
return method.method === "submit"
? method.params.submission_request_id
: method.params.notification_request_id;
}
/** Transport send is not admission. Keep exact typed input and idempotency IDs
* until an authoritative acknowledgement; uncertain sends retain upload leases.
*/
export class ComposerAdmissions {
private records = new Map<string, ComposerAdmission>();
get(target: string): ComposerAdmission | null {
return this.records.get(target) ?? null;
}
begin(
target: string,
method: ComposerAdmissionMethod,
snapshot: ComposerDraftSnapshot,
delivery: ComposerDelivery,
): ComposerAdmission {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
throw new Error(
"Resolve the in-flight admission before editing or sending a new draft.",
);
}
const unchanged = prior && prior.method.method === method.method &&
JSON.stringify(prior.snapshot.segments) ===
JSON.stringify(snapshot.segments) &&
(prior.snapshot.textPastes.length > 0) ===
(snapshot.textPastes.length > 0);
const record: ComposerAdmission = unchanged
? { ...prior, status: "waiting" }
: {
// Route-cached drafts may contain Svelte proxies. Snapshot the actual
// JSON wire values rather than trying to structured-clone those proxies.
method: JSON.parse(JSON.stringify(method)) as ComposerAdmissionMethod,
snapshot: JSON.parse(JSON.stringify(snapshot)) as ComposerDraftSnapshot,
delivery,
status: "waiting",
};
this.records.set(target, record);
return record;
}
retry(target: string): ComposerAdmission | null {
const prior = this.get(target);
if (!prior || prior.status === "rejected") return null;
const record: ComposerAdmission = { ...prior, status: "waiting" };
this.records.set(target, record);
return record;
}
disconnected(target: string): void {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
this.records.set(target, { ...prior, status: "unknown" });
}
}
protects(segment: Segment): boolean {
if (segment.kind !== "uploaded_file") return false;
return [...this.records.values()].some((record) =>
record.status !== "rejected" &&
record.snapshot.segments.some((candidate) =>
candidate.kind === "uploaded_file" &&
candidate.file.artifact_id === segment.file.artifact_id
)
);
}
acknowledge(
target: string,
event: Event,
): { record: ComposerAdmission; accepted: boolean; message?: string } | null {
const record = this.get(target);
if (!record) return null;
let id: string;
let accepted: boolean;
let message: string | undefined;
if (
(event.event === "submission_accepted" ||
event.event === "submission_rejected") &&
record.method.method === "submit"
) {
id = event.data.submission_request_id;
accepted = event.event === "submission_accepted";
if (event.event === "submission_rejected") message = event.data.message;
} else if (
(event.event === "notification_accepted" ||
event.event === "notification_rejected") &&
record.method.method === "notify"
) {
id = event.data.notification_request_id;
accepted = event.event === "notification_accepted";
if (event.event === "notification_rejected") message = event.data.message;
} else return null;
if (id !== admissionId(record.method)) return null;
if (accepted) this.records.delete(target);
else this.records.set(target, { ...record, status: "rejected" });
return { record, accepted, message };
}
}
export type ComposerDelivery = "submit" | "queue" | "notify";
export type ComposerDeliveryState = {
@@ -22,7 +142,8 @@ export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
case "submit":
return state.workerState === "idle" && hasInput;
case "queue":
return (state.workerState === "running" || state.workerState === "paused") &&
return (state.workerState === "running" ||
state.workerState === "paused") &&
hasInput;
case "notify":
return state.workerState === "running" && state.hasText &&
@@ -3,6 +3,8 @@ import {
composerDeletionRange,
type ComposerPaste,
composerPasteToken,
composerTypedAtoms,
composerTypedToken,
pasteChipLabel,
snapshotComposerDraft,
} from "#lib/workspace/console/composer-draft.ts";
@@ -223,6 +225,50 @@ Deno.test("Paste content beginning with a colon remains opaque user input", () =
assertEquals(afterWhitespace.request.content, " :not-a-command\r\n");
});
Deno.test("typed invocation and attachment atoms round-trip in source order and delete atomically", () => {
const invocation: Segment = {
kind: "feature_invoke",
invocation: {
invocation_id: "invoke-1",
identity: "builtin:test/run",
name: "run",
arguments: [{ name: "value", value: { kind: "string", value: "資料 x" } }],
},
};
const attachment: Segment = {
kind: "uploaded_file",
file: {
artifact_id: "artifact-1",
file_name: "report.md",
media_type: "text/markdown",
created_at_ms: 1,
availability: "available",
byte_len: 4,
sha256: "abcd",
},
};
const first = composerTypedToken(10);
const second = composerTypedToken(11);
const document = `before ${first} between ${second} after`;
const registry = new Map([
[10, { segment: invocation, label: '/run(value="資料 x")' }],
[11, { segment: attachment, label: "Attached report.md", cleanup: true }],
]);
const snapshot = snapshotComposerDraft(document, new Map(), [], registry);
assertEquals(snapshot.segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"uploaded_file",
"text",
]);
const atoms = composerTypedAtoms(document, registry);
assertEquals(
composerDeletionRange({ from: atoms[1].to, to: atoms[1].to, head: atoms[1].to }, atoms, "backward"),
{ from: atoms[1].from, to: atoms[1].to },
);
});
Deno.test("plain short-paste Text retains the existing composer request path", () => {
const result = buildComposerSegmentsRequest([
{ kind: "text", content: " short\r\npaste\r\n " },
@@ -3,6 +3,9 @@ import type { Segment } from "#lib/generated/protocol.ts";
const PASTE_TOKEN_PREFIX = "\uFFF9";
const PASTE_TOKEN_SUFFIX = "\uFFFB";
const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g;
const TYPED_TOKEN_PREFIX = "\uFFF8";
const TYPED_TOKEN_SUFFIX = "\uFFFA";
const TYPED_TOKEN_PATTERN = /\uFFF8(\d+)\uFFFA/g;
export interface ComposerPaste {
id: number;
@@ -24,6 +27,19 @@ export interface ComposerTextPaste {
content: string;
}
export interface ComposerTypedSegment {
segment: Segment;
label: string;
/** Whether deleting this unsent atom must release client-staged state. */
cleanup?: boolean;
}
export interface ComposerTypedAtom extends ComposerTypedSegment {
key: number;
from: number;
to: number;
}
export interface ComposerSelection {
from: number;
to: number;
@@ -32,7 +48,7 @@ export interface ComposerSelection {
export function composerDeletionRange(
selection: ComposerSelection,
pastes: readonly ComposerPasteAtom[],
pastes: readonly { from: number; to: number }[],
direction: "backward" | "forward",
): { from: number; to: number } | null {
if (selection.from !== selection.to) {
@@ -75,6 +91,29 @@ export function composerPasteAtoms(
return atoms;
}
export function composerTypedToken(key: number): string {
return `${TYPED_TOKEN_PREFIX}${key}${TYPED_TOKEN_SUFFIX}`;
}
export function composerTypedAtoms(
document: string,
registry: ReadonlyMap<number, ComposerTypedSegment>,
): ComposerTypedAtom[] {
const atoms: ComposerTypedAtom[] = [];
for (const match of document.matchAll(TYPED_TOKEN_PATTERN)) {
const key = Number(match[1]);
const atom = registry.get(key);
if (!atom || match.index === undefined) continue;
atoms.push({
...atom,
key,
from: match.index,
to: match.index + match[0].length,
});
}
return atoms;
}
function appendTextSegment(segments: Segment[], content: string): void {
if (content.length === 0) return;
const previous = segments.at(-1);
@@ -89,8 +128,10 @@ export function snapshotComposerDraft(
document: string,
registry: ReadonlyMap<number, ComposerPaste>,
candidateTextPastes: readonly ComposerTextPaste[] = [],
typedRegistry: ReadonlyMap<number, ComposerTypedSegment> = new Map(),
): ComposerDraftSnapshot {
const pastes = composerPasteAtoms(document, registry);
const typedAtoms = composerTypedAtoms(document, typedRegistry);
const textPastes = candidateTextPastes
.filter((paste) =>
paste.from >= 0 &&
@@ -105,6 +146,12 @@ export function snapshotComposerDraft(
to: paste.to,
paste,
})),
...typedAtoms.map((atom) => ({
kind: "typed" as const,
from: atom.from,
to: atom.to,
atom,
})),
...textPastes.map((paste) => ({
kind: "text_paste" as const,
from: paste.from,
@@ -130,10 +177,14 @@ export function snapshotComposerDraft(
chars: event.paste.chars,
lines: event.paste.lines,
});
content += event.paste.content;
} else if (event.kind === "typed") {
segments.push(event.atom.segment);
content += event.atom.label;
} else {
appendTextSegment(segments, event.paste.content);
content += event.paste.content;
}
content += event.paste.content;
cursor = event.to;
}
@@ -29,7 +29,7 @@ type ComposerHistoryStorage = Pick<Storage, "getItem" | "setItem">;
function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry {
return {
segments: entry.segments.map((segment) => ({ ...segment })) as Segment[],
segments: JSON.parse(JSON.stringify(entry.segments)) as Segment[],
preserveExactText: entry.preserveExactText,
};
}
@@ -45,6 +45,18 @@ function isSegment(value: unknown): value is Segment {
typeof segment.lines === "number";
}
if (segment.kind === "file_ref") return typeof segment.path === "string";
if (segment.kind === "feature_invoke") {
const invocation = segment.invocation as Record<string, unknown> | undefined;
return Boolean(invocation && typeof invocation.invocation_id === "string" &&
typeof invocation.identity === "string" && typeof invocation.name === "string" &&
Array.isArray(invocation.arguments) && invocation.arguments.every((argument) => {
if (!argument || typeof argument !== "object" || typeof argument.name !== "string") return false;
const value = argument.value;
return value && ((value.kind === "string" && typeof value.value === "string") ||
(value.kind === "integer" && Number.isSafeInteger(value.value) && value.value >= -2147483648 && value.value <= 2147483647) ||
(value.kind === "boolean" && typeof value.value === "boolean"));
}));
}
return false;
}
@@ -0,0 +1,239 @@
import { throws } from "node:assert/strict";
import type { FeatureInvocationDescriptor } from "#lib/generated/protocol.ts";
import {
activeInvocationArgument,
finalizeSelectedFeatureInvocations,
invocationInput,
selectedFeatureInvocationRanges,
} from "./composer-invocation.ts";
declare const Deno: { test(name: string, fn: () => void): void };
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const left = JSON.stringify(actual);
const right = JSON.stringify(expected);
if (left !== right) throw new Error(`Expected ${right}, got ${left}`);
}
const descriptor: FeatureInvocationDescriptor = {
identity: "builtin:test/run",
name: "run",
aliases: ["execute"],
display_name: "Run",
description: "Run a declared operation",
syntax: "parenthesized",
arguments: [
{
name: "path",
position: 0,
required: true,
value_type: { kind: "worker_file" },
completion: { kind: "worker_file" },
},
{
name: "mode",
required: false,
value_type: { kind: "enum", values: ["safe", "fast"] },
completion: { kind: "static", values: ["safe", "fast"] },
},
],
};
Deno.test("selected invocation parsing preserves Unicode, spaces, slashes, prose, and multiple calls", () => {
const text = '前 /run("資料/a b/c", mode=safe) 後 /execute("x/y") done';
const ranges = selectedFeatureInvocationRanges(text, [descriptor]);
assertEquals(ranges.length, 2);
assertEquals(text.slice(ranges[0].end), ' 後 /execute("x/y") done');
assertEquals(ranges[0].invocation.arguments.map((argument) => argument.value), [
{ kind: "string", value: "資料/a b/c" },
{ kind: "string", value: "safe" },
]);
const segments = finalizeSelectedFeatureInvocations(
[{ kind: "text", content: text }],
[descriptor],
);
assertEquals(segments.map((segment) => segment.kind), [
"text",
"feature_invoke",
"text",
"feature_invoke",
"text",
]);
});
Deno.test("unselected text, URLs, paths, and paste content are never promoted", () => {
assertEquals(
finalizeSelectedFeatureInvocations(
[
{ kind: "text", content: '/run("x") https://host/run("y") /tmp/run("z")' },
{ kind: "paste", id: 1, chars: 9, lines: 1, content: '/run("p")' },
],
[],
).map((segment) => segment.kind),
["text", "paste"],
);
});
Deno.test("argument completion ignores quoted invocation-looking data and escaped quotes", () => {
for (const [input, prefix] of [
['/run("a /run(pa", mode=safe) prose', 'a /run(pa'],
[String.raw`/run(path="a \" /execute(pa", mode=safe) prose`, 'a " /execute(pa'],
[String.raw`/run(path="a \" /run(pa, mode=safe) prose`, 'a " /run(pa'],
]) {
const dataCursor = input.lastIndexOf("(pa") + 3;
for (const occurrences of [undefined, [{ from: 0, descriptor }]]) {
const active = activeInvocationArgument(input, dataCursor, [descriptor], occurrences);
assertEquals(active?.descriptor.identity, descriptor.identity);
assertEquals(active?.argument?.name, "path");
assertEquals(active?.start, input.indexOf('"'));
assertEquals(active?.prefix, prefix);
}
}
const input = '/run("a /run(pa", mode=safe) prose /execute("new")';
const active = activeInvocationArgument(input, input.indexOf("new") + 2, [descriptor]);
assertEquals(active?.prefix, "ne");
assertEquals(active?.start, input.lastIndexOf('"new'));
assertEquals(activeInvocationArgument(input, input.indexOf("prose") + 3, [descriptor]), null);
const unknown = '/unknown("a /run(pa", mode=safe) prose';
assertEquals(activeInvocationArgument(unknown, unknown.lastIndexOf("pa") + 2, [descriptor]), null);
const quotedProse = 'quoted "a /run(pa" prose';
assertEquals(activeInvocationArgument(quotedProse, quotedProse.lastIndexOf("pa") + 2, [descriptor]), null);
});
Deno.test("editor occurrence anchors exclude unselected matching call contexts", () => {
const input = '/run("literal /run(unfinished data before /execute("selected")';
const from = input.indexOf("/execute(");
const active = activeInvocationArgument(input, input.indexOf("selected") + 3, [descriptor], [{ from, descriptor }]);
assertEquals(active?.prefix, "sel");
assertEquals(active?.start, input.indexOf('"selected'));
assertEquals(activeInvocationArgument(input, input.indexOf("literal") + 2, [descriptor], []), null);
});
Deno.test("incomplete quoted values bound completion replacement to the cursor", () => {
for (const input of ['/run(path="ab, mode=safe) following prose', '/run("ab, mode=safe) following prose', String.raw`/run(path="ab\q", mode=safe) following prose`]) {
const cursor = input.indexOf("ab") + 2;
const active = activeInvocationArgument(input, cursor, [descriptor]);
assertEquals(active?.end, cursor);
assertEquals(active?.prefix, "ab");
}
});
Deno.test("Feature strings reject lone surrogate escapes and code units like Rust JSON", () => {
for (const literal of [String.raw`"\uD800"`, String.raw`"\uDC00"`, String.raw`"\uD800x"`, String.raw`"\uD800\uD800"`, String.raw`"\uDC00\uD800"`, JSON.stringify("\uD800"), JSON.stringify("\uDC00")]) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), /unpaired Unicode surrogates/);
const input = `/run(${literal})`;
assertEquals(activeInvocationArgument(input, input.length - 1, [descriptor]), null);
}
throws(() => selectedFeatureInvocationRanges('/run("\uD800")', [descriptor]), /unpaired Unicode surrogates/);
throws(() => selectedFeatureInvocationRanges('/run(\uDC00)', [descriptor]), /unpaired Unicode surrogates/);
for (const literal of [String.raw`"\uD83D\uDE00"`, '"😀"', String.raw`"\uDBFF\uDFFF"`, String.raw`"\u0000\b\f\n\r\t\"\\\/資料"`]) {
const parsed = selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: JSON.parse(literal) });
}
for (const literal of [String.raw`"\q"`, String.raw`"\u12xz"`, '"line\nfeed"']) {
throws(() => selectedFeatureInvocationRanges(`/run(${literal})`, [descriptor]), SyntaxError);
}
});
Deno.test("argument completion is cursor-aware and typed invocation round-trips to input", () => {
const named = activeInvocationArgument('/run("x", mo', 12, [descriptor]);
assert(named, "named argument completion should be active");
assertEquals(named.argument, null);
assertEquals(named.prefix, "mo");
const value = activeInvocationArgument('/run("x", mode=sa', 18, [descriptor]);
assert(value?.argument, "enum value completion should resolve its descriptor");
assertEquals(value.argument.name, "mode");
assertEquals(value.prefix, "sa");
const invocation = selectedFeatureInvocationRanges('/run("a b", mode=fast)', [descriptor])[0]
.invocation;
assertEquals(invocationInput(invocation), '/run(path="a b", mode="fast")');
});
Deno.test("complete invalid calls reject unknown, duplicate, missing, and mistyped arguments", () => {
for (const input of [
'/run(other="x")',
'/run("x", path="y")',
"/run()",
'/run("x", mode=invalid)',
]) {
let rejected = false;
try {
selectedFeatureInvocationRanges(input, [descriptor]);
} catch {
rejected = true;
}
assert(rejected, `${input} should be rejected`);
}
});
Deno.test("a leading slash without a selected descriptor terminates without promotion", () => {
assertEquals(activeInvocationArgument("/ru", 3, []), null);
assertEquals(activeInvocationArgument("/unknown(", 9, [descriptor]), null);
});
Deno.test("integer invocation arguments reject unsafe JavaScript wire values", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
let rejected = false;
try { selectedFeatureInvocationRanges("/run(9007199254740992)", [integerDescriptor]); }
catch { rejected = true; }
assert(rejected, "unsafe integers must reject before sending to Rust");
});
Deno.test("structured invocation-only Submit and mixed source order retain exact typed payloads", async () => {
const { buildComposerSegmentsRequest } = await import("./composer-command.ts");
const invocation = finalizeSelectedFeatureInvocations([{ kind: "text", content: '/run("a b/資料")' }], [descriptor])[0];
const segments = [{ kind: "text" as const, content: "before " }, invocation, { kind: "text" as const, content: " after" }];
const result = buildComposerSegmentsRequest(segments);
assert(result.ok && result.request, "mixed invocation should build a Submit");
assertEquals(result.request.segments, segments);
const only = buildComposerSegmentsRequest([invocation]);
assert(only.ok && only.request?.segments?.[0].kind === "feature_invoke", "invocation-only Submit must not become empty text");
});
Deno.test("positional completion after a named-only argument uses positional rather than comma index", () => {
const input = '/run(mode=safe, "';
assertEquals(activeInvocationArgument(input, input.length, [descriptor])?.argument?.name, "path");
});
Deno.test("Feature string parsing decodes full JSON escapes and preserves round-trip Unicode", () => {
const raw = String.raw`/run("a\b\f\n\r\t\"\\\/\u0000\uD83D\uDE00")`;
const parsed = selectedFeatureInvocationRanges(raw, [descriptor])[0].invocation;
assertEquals(parsed.arguments[0].value, { kind: "string", value: 'a\b\f\n\r\t"\\/\u0000😀' });
const restored = selectedFeatureInvocationRanges(invocationInput(parsed), [descriptor])[0].invocation;
assertEquals(restored.arguments, parsed.arguments);
});
Deno.test("Feature integer lexical syntax matches negative-or-positive digits only", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const [raw, value] of [["-0", 0], ["0012", 12], ["-0012", -12]] as const) {
assertEquals(selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const raw of ["+1", "1.0", "1e0", "NaN", "Infinity"]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${raw})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, `${raw} must reject`);
}
});
Deno.test("invocation integer parsing matches the generated Rust int32 schema", () => {
const integerDescriptor: FeatureInvocationDescriptor = { ...descriptor,
arguments: [{ name: "count", position: 0, required: true, value_type: { kind: "integer" }, completion: { kind: "none" } }] };
for (const value of [-2147483648, 2147483647]) {
assertEquals(selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor])[0].invocation.arguments[0].value,
{ kind: "integer", value });
}
for (const value of [-2147483649, 2147483648]) {
let rejected = false;
try { selectedFeatureInvocationRanges(`/run(${value})`, [integerDescriptor]); } catch { rejected = true; }
assert(rejected, "values outside Rust int32 range must reject");
}
});
@@ -0,0 +1,433 @@
import type {
FeatureInvocation,
FeatureInvocationDescriptor,
InvocationArgumentDescriptor,
InvocationArgumentValue,
InvocationValue,
Segment,
} from "#lib/generated/protocol.ts";
export type ActiveInvocationArgument = {
descriptor: FeatureInvocationDescriptor;
argument: InvocationArgumentDescriptor | null;
start: number;
end: number;
prefix: string;
};
export function quoteInvocationString(value: string): string {
return JSON.stringify(value);
}
export function invocationInput(invocation: FeatureInvocation): string {
const argumentsText = invocation.arguments.map((argument) =>
`${argument.name}=${invocationValueInput(argument.value)}`
).join(", ");
return `/${invocation.name}(${argumentsText})`;
}
function invocationValueInput(value: InvocationValue): string {
switch (value.kind) {
case "string":
return quoteInvocationString(value.value);
case "integer":
case "boolean":
return String(value.value);
}
}
export type SelectedInvocationOccurrence = {
from: number;
descriptor: FeatureInvocationDescriptor;
};
export function activeInvocationArgument(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
selectedOccurrences?: readonly SelectedInvocationOccurrence[],
): ActiveInvocationArgument | null {
const bounded = Math.max(0, Math.min(cursor, value.length));
const occurrences = selectedOccurrences ?? activeInvocationOccurrences(value, bounded, descriptors);
for (const { from: slash, descriptor } of occurrences) {
if (slash < 0 || slash >= bounded) continue;
const tail = value.slice(slash, bounded);
if (![descriptor.name, ...descriptor.aliases].some((name) => tail.startsWith(`/${name}(`))) continue;
const open = tail.indexOf("(");
const argumentsText = tail.slice(open + 1);
if (hasUnquotedClose(argumentsText)) continue;
const currentOffset = lastUnquotedComma(argumentsText) + 1;
const rawCurrent = argumentsText.slice(currentOffset);
const leading = rawCurrent.length - rawCurrent.trimStart().length;
const current = rawCurrent.trimStart();
const equal = unquotedEqual(current);
if (equal >= 0) {
const name = current.slice(0, equal).trim();
const argument = descriptor.arguments.find((candidate) => candidate.name === name) ?? null;
let valueOffset = equal + 1;
while (/\s/.test(current[valueOffset] ?? "")) valueOffset++;
const prefix = invocationCompletionPrefix(current.slice(valueOffset));
if (prefix === null) return null;
return {
descriptor,
argument,
start: slash + open + 1 + currentOffset + leading + valueOffset,
end: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading + valueOffset),
prefix,
};
}
const position = countPositionalArguments(argumentsText.slice(0, currentOffset));
const positional = descriptor.arguments.find((candidate) => candidate.position === position) ?? null;
const looksLikeArgumentName = /^[a-z][a-z0-9_]*$/.test(current) &&
descriptor.arguments.some((candidate) => candidate.name.startsWith(current));
const prefix = invocationCompletionPrefix(current);
if (prefix === null) return null;
return {
descriptor,
argument: looksLikeArgumentName ? null : positional,
start: slash + open + 1 + currentOffset + leading,
end: looksLikeArgumentName
? bounded + (/^[a-z0-9_]*/.exec(value.slice(bounded))?.[0].length ?? 0)
: invocationValueEnd(value, bounded, slash + open + 1 + currentOffset + leading),
prefix,
};
}
return null;
}
/** Scan forward and skip entire quoted argument regions, never treating a slash
* inside an unfinished outer value as a new call. The editor supplies selected
* occurrence anchors instead of relying on descriptor-only discovery.
*/
function activeInvocationOccurrences(
value: string,
cursor: number,
descriptors: readonly FeatureInvocationDescriptor[],
): SelectedInvocationOccurrence[] {
let active: SelectedInvocationOccurrence | null = null;
scanEachUnquoted(value.slice(0, cursor), (char, slash) => {
if (active) {
if (char === ")") active = null;
return;
}
if (char !== "/" || (slash > 0 && !/\s/.test(value[slash - 1]))) return;
const descriptor = descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) => value.startsWith(`/${name}(`, slash))
);
if (descriptor) active = { from: slash, descriptor };
});
return active ? [active] : [];
}
function invocationCompletionPrefix(value: string): string | null {
let prefix = value;
if (value.startsWith('"')) {
try {
prefix = JSON.parse(value.endsWith('"') ? value : `${value}"`);
} catch {
prefix = value.slice(1);
}
}
try {
return validInvocationString(prefix);
} catch {
// Never send a completion prefix that Rust's JSON reader cannot decode.
return null;
}
}
function invocationValueEnd(text: string, cursor: number, start: number): number {
try {
const parsed = parseRawValue(text, start);
// Without a complete, valid token the suffix is untouched user input.
return parsed && parsed.end >= cursor ? parsed.end : cursor;
} catch {
return cursor;
}
}
export function selectedFeatureInvocationAt(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): ParsedInvocationRange | null {
const parsed = parseFeatureInvocation(text, start, descriptor);
return parsed ? { start, end: parsed.end, invocation: parsed.invocation } : null;
}
export type ParsedInvocationRange = {
start: number;
end: number;
invocation: FeatureInvocation;
};
/** Parse complete invocations selected from completion without promoting arbitrary text. */
export function selectedFeatureInvocationRanges(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): ParsedInvocationRange[] {
const ranges: ParsedInvocationRange[] = [];
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const descriptor = (slash === 0 || /\s/.test(text[slash - 1]))
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
ranges.push({ start: slash, end: parsed.end, invocation: parsed.invocation });
search = parsed.end;
}
return ranges;
}
export function finalizeSelectedFeatureInvocations(
segments: readonly Segment[],
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind !== "text") {
result.push(segment);
continue;
}
result.push(...parseTextInvocations(segment.content, descriptors));
}
return coalesceText(result);
}
function parseTextInvocations(
text: string,
descriptors: readonly FeatureInvocationDescriptor[],
): Segment[] {
const result: Segment[] = [];
let emitted = 0;
let search = 0;
while (search < text.length) {
const slash = text.indexOf("/", search);
if (slash < 0) break;
const leadingOk = slash === 0 || /\s/.test(text[slash - 1]);
const descriptor = leadingOk
? descriptors.find((candidate) =>
[candidate.name, ...candidate.aliases].some((name) =>
text.startsWith(`/${name}(`, slash)
)
)
: undefined;
if (!descriptor) {
search = slash + 1;
continue;
}
const parsed = parseFeatureInvocation(text, slash, descriptor);
if (!parsed) {
search = slash + 1;
continue;
}
appendText(result, text.slice(emitted, slash));
result.push({ kind: "feature_invoke", invocation: parsed.invocation });
emitted = parsed.end;
search = parsed.end;
}
if (!result.length) return [{ kind: "text", content: text }];
appendText(result, text.slice(emitted));
return result;
}
function parseFeatureInvocation(
text: string,
start: number,
descriptor: FeatureInvocationDescriptor,
): { invocation: FeatureInvocation; end: number } | null {
let cursor = start + 1;
while (/[a-z0-9_-]/.test(text[cursor] ?? "")) cursor++;
const name = text.slice(start + 1, cursor);
if (name !== descriptor.name && !descriptor.aliases.includes(name)) return null;
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] !== "(") return null;
cursor++;
const values: InvocationArgumentValue[] = [];
let position = 0;
while (true) {
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ")") {
cursor++;
break;
}
if (cursor >= text.length) return null;
const itemStart = cursor;
while (/[a-z0-9_]/.test(text[cursor] ?? "")) cursor++;
const possibleName = text.slice(itemStart, cursor);
while (/\s/.test(text[cursor] ?? "")) cursor++;
let argument: InvocationArgumentDescriptor | undefined;
if (possibleName && text[cursor] === "=") {
argument = descriptor.arguments.find((candidate) => candidate.name === possibleName);
if (!argument) throw new Error(`Unknown Feature argument: ${possibleName}`);
cursor++;
while (/\s/.test(text[cursor] ?? "")) cursor++;
} else {
cursor = itemStart;
argument = descriptor.arguments.find((candidate) => candidate.position === position++);
if (!argument) throw new Error("Too many positional Feature arguments.");
}
const parsed = parseRawValue(text, cursor);
if (!parsed) return null;
cursor = parsed.end;
if (values.some((candidate) => candidate.name === argument.name)) {
throw new Error(`Duplicate Feature argument: ${argument.name}`);
}
values.push({ name: argument.name, value: coerceValue(argument, parsed.value) });
while (/\s/.test(text[cursor] ?? "")) cursor++;
if (text[cursor] === ",") cursor++;
else if (text[cursor] !== ")") return null;
}
for (const argument of descriptor.arguments) {
if (argument.required && !values.some((value) => value.name === argument.name)) {
throw new Error(`Missing required Feature argument: ${argument.name}`);
}
}
return {
invocation: {
invocation_id: crypto.randomUUID(),
identity: descriptor.identity,
name,
arguments: values,
},
end: cursor,
};
}
function parseRawValue(text: string, start: number): { value: string; end: number } | null {
if (text[start] === '"') {
let cursor = start + 1;
let escaped = false;
while (cursor < text.length) {
const char = text[cursor];
if (!escaped && char === '"') {
const literal = text.slice(start, cursor + 1);
return { value: validInvocationString(JSON.parse(literal)), end: cursor + 1 };
}
escaped = !escaped && char === "\\";
if (char !== "\\") escaped = false;
cursor++;
}
return null;
}
let cursor = start;
while (cursor < text.length && !/[\s,)]/.test(text[cursor])) cursor++;
return cursor === start ? null : { value: validInvocationString(text.slice(start, cursor)), end: cursor };
}
/** Rust JSON strings contain Unicode scalar values, not lone UTF-16 code units.
* JSON.parse alone permits unpaired surrogate escapes that serde_json rejects.
*/
function validInvocationString(value: string): string {
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit >= 0xD800 && unit <= 0xDBFF) {
const next = value.charCodeAt(++index);
if (next >= 0xDC00 && next <= 0xDFFF) continue;
} else if (unit < 0xDC00 || unit > 0xDFFF) continue;
throw new Error("Feature string arguments must not contain unpaired Unicode surrogates.");
}
return value;
}
function coerceValue(argument: InvocationArgumentDescriptor, value: string): InvocationValue {
switch (argument.value_type.kind) {
case "integer": {
if (!/^-?\d+$/.test(value)) throw new Error(`Feature argument ${argument.name} must be an integer.`);
const integer = Number(value);
if (!Number.isSafeInteger(integer)) {
throw new Error(`Feature argument ${argument.name} must be a safe integer.`);
}
if (integer < -2147483648 || integer > 2147483647) {
throw new Error(`Feature argument ${argument.name} must be a signed 32-bit integer.`);
}
return { kind: "integer", value: integer };
}
case "boolean":
if (value !== "true" && value !== "false") {
throw new Error(`Feature argument ${argument.name} must be true or false.`);
}
return { kind: "boolean", value: value === "true" };
case "enum":
if (!argument.value_type.values.includes(value)) {
throw new Error(`Feature argument ${argument.name} is not an allowed value.`);
}
return { kind: "string", value };
case "string":
case "worker_file":
case "client_file":
return { kind: "string", value };
}
}
function appendText(segments: Segment[], content: string): void {
if (!content) return;
const prior = segments.at(-1);
if (prior?.kind === "text") prior.content += content;
else segments.push({ kind: "text", content });
}
function coalesceText(segments: Segment[]): Segment[] {
const result: Segment[] = [];
for (const segment of segments) {
if (segment.kind === "text") appendText(result, segment.content);
else result.push(segment);
}
return result;
}
// These helpers only inspect syntax before the cursor. They deliberately do not
// execute or finalize anything.
function hasUnquotedClose(value: string): boolean {
return scanUnquoted(value, (char) => char === ")") >= 0;
}
function lastUnquotedComma(value: string): number {
let last = -1;
scanEachUnquoted(value, (char, index) => { if (char === ",") last = index; });
return last;
}
function unquotedEqual(value: string): number {
return scanUnquoted(value, (char) => char === "=");
}
function countPositionalArguments(value: string): number {
let count = 0;
let start = 0;
scanEachUnquoted(value, (char, index) => {
if (char !== ",") return;
if (value.slice(start, index).trim() && unquotedEqual(value.slice(start, index)) < 0) count++;
start = index + 1;
});
return count;
}
function scanUnquoted(value: string, predicate: (char: string) => boolean): number {
let found = -1;
scanEachUnquoted(value, (char, index) => {
if (found < 0 && predicate(char)) found = index;
});
return found;
}
function scanEachUnquoted(value: string, visit: (char: string, index: number) => void): void {
let quoted = false;
let escaped = false;
for (let index = 0; index < value.length; index++) {
const char = value[index];
if (!escaped && char === '"') quoted = !quoted;
if (!quoted) visit(char, index);
escaped = quoted && !escaped && char === "\\";
if (char !== "\\") escaped = false;
}
}
@@ -1,70 +1,79 @@
import { FileCompletions } from "./file-completions.ts";
import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict";
async function assertRejects(
fn: () => Promise<unknown>,
_type: typeof Error,
message: string,
) {
await rejects(
fn,
(error: unknown) =>
error instanceof Error && error.message.includes(message),
);
}
import {
deepStrictEqual as assertEquals,
notEqual,
rejects,
} from "node:assert/strict";
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
Deno.test("file completion serializes the latest prefix behind cancelled wire replies", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix));
Deno.test("completion nonces fence same prefix/context ABA without draining cancelled requests", async () => {
const sent: Array<[string, string]> = [];
const lane = new FileCompletions((prefix, id) => sent.push([prefix, id]));
const first = new AbortController();
const a = lane.request("a", first.signal);
const a = lane.request("same", first.signal);
first.abort();
assertEquals(await a, []);
const second = new AbortController();
const b = lane.request("b", second.signal);
second.abort();
const c = lane.request("c", new AbortController().signal);
const b = lane.request("other", new AbortController().signal);
const c = lane.request("same", new AbortController().signal);
assertEquals(await b, []);
assertEquals(sent, ["a"]);
lane.receive([{ value: "old-a" }]);
assertEquals(sent, ["a", "c"]);
lane.receive([{ value: "current-c" }]);
assertEquals(sent.map(([prefix]) => prefix), ["same", "other", "same"]);
notEqual(sent[0][1], sent[2][1]);
lane.receive([{ value: "old-a" }], "same", sent[0][1]);
lane.receive([{ value: "legacy" }], "same");
lane.receive([{ value: "wrong-prefix" }], "other", sent[2][1]);
assertEquals(lane.pending, true);
lane.receive([{ value: "current-c" }], "same", sent[2][1]);
assertEquals(await c, [{ value: "current-c" }]);
lane.close();
});
Deno.test("file completion timeout blocks late responses until a new connection", async () => {
const sent: string[] = [];
const lane = new FileCompletions((prefix) => sent.push(prefix), 5);
await assertRejects(
() => lane.request("a", new AbortController().signal),
Error,
"timed out",
Deno.test("multiple clients cannot consume each other's identical completion broadcasts", async () => {
const ids: string[] = [];
const lanes = [
new FileCompletions((_prefix, id) => ids.push(id)),
new FileCompletions((_prefix, id) => ids.push(id)),
];
const promises = lanes.map((lane) =>
lane.request("same", new AbortController().signal)
);
lane.receive([{ value: "late-a" }]);
await assertRejects(
() => lane.request("b", new AbortController().signal),
Error,
"timed out",
notEqual(ids[0], ids[1]);
for (const lane of lanes) {
lane.receive([{ value: "client-a" }], "same", ids[0]);
}
assertEquals(await promises[0], [{ value: "client-a" }]);
assertEquals(lanes[1].pending, true);
for (const lane of lanes) {
lane.receive([{ value: "client-b" }], "same", ids[1]);
}
assertEquals(await promises[1], [{ value: "client-b" }]);
lanes.forEach((lane) => lane.close());
});
Deno.test("completion timeout and reconnect invalidate old nonces but allow a fresh request", async () => {
const ids: string[] = [];
const lane = new FileCompletions((_prefix, id) => ids.push(id), 5);
await rejects(
lane.request("same", new AbortController().signal),
/timed out/,
);
assertEquals(sent, ["a"]);
lane.reset();
const c = lane.request("c", new AbortController().signal);
lane.receive([{ value: "c" }]);
assertEquals(await c, [{ value: "c" }]);
const current = lane.request("same", new AbortController().signal);
lane.receive([{ value: "old" }], "same", ids[0]);
assertEquals(lane.pending, true);
lane.receive([{ value: "new" }], "same", ids[1]);
assertEquals(await current, [{ value: "new" }]);
lane.close();
});
Deno.test("file completion close rejects both active and queued requests", async () => {
Deno.test("completion close rejects active request and fences requests until reset", async () => {
const lane = new FileCompletions(() => {});
const a = lane.request("a", new AbortController().signal);
const b = lane.request("b", new AbortController().signal);
const outcomes = Promise.allSettled([a, b]);
const rejected = rejects(a, /disconnected/);
lane.close(new Error("disconnected"));
assertEquals((await outcomes).map((value) => value.status), [
"rejected",
"rejected",
]);
await rejected;
await rejects(
lane.request("b", new AbortController().signal),
/disconnected/,
);
});
@@ -1,6 +1,7 @@
import type { ComposerCompletionEntry } from "./composer-completion.ts";
type Request = {
id: string;
prefix: string;
signal: AbortSignal;
resolve: (entries: ComposerCompletionEntry[]) => void;
@@ -8,18 +9,18 @@ type Request = {
abort: () => void;
};
/** The wire reply has no request id/prefix. Never overlap requests on a lane,
* including cancelled requests: their reply must be consumed before the next.
* A timeout poisons the lane until reconnect, rather than misrouting a late reply.
/** A lane is scoped to a transport target and completion kind/context. Each
* request also has a unique wire nonce: cancelled requests never need draining,
* and legacy replies or another client's same-prefix broadcasts cannot match.
* The editor's abort signal fences the draft, cursor and completion generation.
*/
export class FileCompletions {
private active: Request | null = null;
private queued: Request | null = null;
private timer: ReturnType<typeof setTimeout> | undefined;
private failure: Error | null = null;
constructor(
private send: (prefix: string) => void,
private send: (prefix: string, requestId: string) => void,
private timeoutMs = 30_000,
) {}
@@ -33,42 +34,48 @@ export class FileCompletions {
): Promise<ComposerCompletionEntry[]> {
if (signal.aborted) return Promise.resolve([]);
if (this.failure) return Promise.reject(this.failure);
this.finish([]);
return new Promise((resolve, reject) => {
const request: Request = {
id: crypto.randomUUID(),
prefix,
signal,
resolve,
reject,
abort: () => {
resolve([]);
if (this.queued === request) this.queued = null;
if (this.active === request) this.finish([]);
},
};
this.active = request;
signal.addEventListener("abort", request.abort, { once: true });
if (this.queued) this.finish(this.queued, []);
this.queued = request;
this.pump();
this.timer = setTimeout(() => {
if (this.active === request) {
this.reject(new Error("Worker completion request timed out; retry."));
}
}, this.timeoutMs);
try {
this.send(prefix, request.id);
} catch (error) {
this.reject(error instanceof Error ? error : new Error(String(error)));
}
});
}
receive(entries: ComposerCompletionEntry[]): void {
if (!this.active) return;
clearTimeout(this.timer);
const request = this.active;
this.active = null;
this.finish(request, entries);
this.pump();
receive(
entries: ComposerCompletionEntry[],
prefix: string,
requestId?: string | null,
): void {
if (
!this.active || this.active.id !== requestId ||
this.active.prefix !== prefix
) return;
this.finish(entries);
}
close(error = new Error("Worker completion connection closed.")): void {
this.failure = error;
clearTimeout(this.timer);
for (const request of [this.active, this.queued]) {
if (!request) continue;
request.signal.removeEventListener("abort", request.abort);
request.reject(error);
}
this.active = this.queued = null;
this.reject(error);
}
reset(): void {
@@ -76,31 +83,20 @@ export class FileCompletions {
this.failure = null;
}
private finish(request: Request, entries: ComposerCompletionEntry[]): void {
request.signal.removeEventListener("abort", request.abort);
request.resolve(request.signal.aborted ? [] : entries);
private take(): Request | null {
clearTimeout(this.timer);
const request = this.active;
this.active = null;
request?.signal.removeEventListener("abort", request.abort);
return request;
}
private pump(): void {
if (this.active || !this.queued || this.failure) return;
const request = this.queued;
this.queued = null;
if (request.signal.aborted) {
this.finish(request, []);
return;
}
this.active = request;
this.timer = setTimeout(
() =>
this.close(
new Error("Worker completion request timed out; reconnect to retry."),
),
this.timeoutMs,
);
try {
this.send(request.prefix);
} catch (error) {
this.close(error instanceof Error ? error : new Error(String(error)));
}
private finish(entries: ComposerCompletionEntry[]): void {
const request = this.take();
request?.resolve(request.signal.aborted ? [] : entries);
}
private reject(error: Error): void {
this.take()?.reject(error);
}
}
@@ -352,7 +352,7 @@ Deno.test("console routing projects live errors but not completion replies", ()
} satisfies Event;
const completionEvent = {
event: "completions",
data: { kind: "file", entries: [] },
data: { kind: "file", prefix: "", entries: [] },
} satisfies Event;
assert(
@@ -3961,3 +3961,11 @@ Deno.test("committed history inserts current live rows after their last stable a
["current-user", "live-assistant"],
);
});
Deno.test("typed Feature invocations remain visible in live and restored user messages", () => {
const segment = { kind: "feature_invoke" as const, invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run",
arguments: [{ name: "path", value: { kind: "string" as const, value: "資料/a b" } }],
} };
assertEquals(segmentsToText([segment]), '/run(path="資料/a b")');
});
@@ -19,6 +19,7 @@ import type {
WorkerStateSnapshot,
WorkerStatus,
} from "#lib/generated/protocol.ts";
import { invocationInput } from "./composer-invocation.ts";
import { stringify as stringifyYaml } from "yaml";
import {
applyRunActivityEvent,
@@ -1344,6 +1345,8 @@ export function segmentsToText(segments: Segment[]): string {
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
case "uploaded_file":
return `[Attachment: ${segment.file.file_name} · ${segment.file.media_type} · ${segment.file.byte_len} bytes · ${segment.file.availability}]`;
case "feature_invoke":
return invocationInput(segment.invocation);
case "file_ref":
return `@file ${segment.path}`;
case "unknown":
@@ -1017,7 +1017,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
composerInput.includes('key: "Backspace"') &&
composerInput.includes('key: "Delete"') &&
composerInput.includes(
"composerDeletionRange(selection, pastes, direction)",
"composerDeletionRange(selection, atoms, direction)",
) &&
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
composerInput.includes('key: "Mod-z"') &&
@@ -1572,8 +1572,8 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
"Completion navigation must precede input history",
);
assert(
page.includes("fileCompletions.receive(event.data.entries)") &&
page.includes("fileCompletions.close()"),
page.includes("fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id)") &&
page.includes("rejectPendingCompletion(new Error(\"Worker completion connection closed.\"), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`)"),
"File completion lifecycle must be scoped to the transport",
);
});
@@ -1701,7 +1701,7 @@ Deno.test("Web Console uses Notify while running and exposes queued input cancel
consolePage.includes("{#if workerRunning || workerPaused}") &&
consolePage.includes('delivery === "queue" && request.kind !== "user"') &&
consolePage.includes(
'method.method === "submit" && delivery === "submit"',
'event.event === "submission_accepted" && event.data.disposition === "started"',
),
"busy Console must offer explicit Queue without changing paused state or queueing commands",
);
@@ -323,3 +323,28 @@ Deno.test('stateful selector validation fences a routed event before projection'
'mismatched Worker event crossed its subscription selector',
);
});
Deno.test('generated subscription schema accepts invocation descriptors, context and typed user segments', () => {
const descriptor = { identity: 'test/run', name: 'run', aliases: ['execute'], display_name: 'Run', description: 'Test',
syntax: 'parenthesized', client_adapter: null, arguments: [{ name: 'count', position: 0, required: true,
value_type: { kind: 'integer' }, completion: { kind: 'static', values: ['1', '2'] } }] };
const frame = (events: unknown[]) => subscribedFrame({
selector: { topic: 'worker_protocol', worker_id: 'worker-1' },
snapshot: { topic: 'worker_protocol', data: { worker_id: 'worker-1', events } },
});
const completion = { event: 'completions', data: { kind: 'feature_argument', prefix: '1',
context: { invocation: 'test/run', argument: 'count' },
entries: [{ value: '1', is_dir: false, invocation: descriptor }] } };
const segment = { kind: 'feature_invoke', invocation: { invocation_id: 'invoke-1', identity: 'test/run', name: 'run',
arguments: [{ name: 'count', value: { kind: 'integer', value: 1 } }] } };
const valid = decode(frame([completion, { event: 'user_message', data: { segments: [segment] } }]));
assert(valid.ok, `invocation schema mismatch: ${JSON.stringify(valid)}`);
const invalid = decode(frame([{ event: 'user_message', data: { segments: [{ ...segment, invocation: {
...segment.invocation, arguments: [{ name: 'count', value: { kind: 'integer', value: 9007199254740992 } }],
} }] } }]));
assert(!invalid.ok, 'generated invocation schema must reject unsafe wire integers');
const newer = decode(frame([{ ...completion, data: { ...completion.data, entries: [
{ value: '1', is_dir: false, invocation: { ...descriptor, client_adapter: 'newer_adapter' } },
] } }]));
assert(!newer.ok, 'unknown adapter capabilities must fail closed');
});
@@ -11,6 +11,8 @@
import ComposerInput from "#lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "#lib/workspace/console/composer-draft.ts";
import {
ComposerAdmissions,
type ComposerAdmission,
canDeliverComposerDraft,
sendComposerDelivery,
type ComposerDelivery,
@@ -38,6 +40,7 @@
type ConsoleViewScroll,
} from "#lib/workspace/console/model.ts";
import type {
CompletionContext,
Event as ProtocolEvent,
Method as ProtocolMethod,
PendingSubmissionsSnapshot,
@@ -115,10 +118,15 @@
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
insertSegment(segment: Segment, cleanup?: boolean): boolean;
reserveUpload(fileName: string): number | null;
completeUpload(key: number, segment: Segment): boolean;
cancelUpload(key: number): void;
clear(acceptedTypedResources?: boolean): void;
restoreSegments(
segments: readonly Segment[],
preserveExactText?: boolean,
ownStagedUploads?: boolean,
): void;
};
@@ -138,9 +146,35 @@
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
let attachments = $state<ComposerAttachment[]>([]);
let nextAttachmentId = 1;
const stagedAttachmentPaths = new Map<string, string>();
const uploadReservations = new Map<number, number>();
let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false);
const admissions = new ComposerAdmissions();
let admission = $state.raw<ComposerAdmission | null>(null);
let sending = $state(false);
function syncAdmission() {
admission = admissions.get(activeComposerTargetKey);
sending = admission !== null && admission.status !== "rejected";
}
function admissionDisconnected(targetKey = activeComposerTargetKey) {
admissions.disconnected(targetKey);
syncAdmission();
}
function retryAdmission() {
const retry = admissions.retry(activeComposerTargetKey);
if (!retry || protocolState !== "open") return;
syncAdmission();
try {
sendProtocolMethod(retry.method);
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
}
}
let rewindTargets = $state<RewindTarget[]>([]);
let rewindHeadEntries = $state(0);
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
@@ -158,9 +192,30 @@
submissions: [],
});
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix },
const fileCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "file", prefix, request_id },
}));
const featureCompletions = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions", params: { kind: "feature", prefix, request_id },
}));
const featureArgumentCompletions = new Map<string, FileCompletions>();
function completionContextKey(context: CompletionContext): string {
return JSON.stringify([context.invocation, context.argument ?? null]);
}
function featureArgumentCompletionLane(context: CompletionContext): FileCompletions {
const key = completionContextKey(context);
let lane = featureArgumentCompletions.get(key);
if (!lane) {
lane = new FileCompletions((prefix, request_id) => sendProtocolMethod({
method: "list_completions",
params: { kind: "feature_argument", prefix, context, request_id },
}));
featureArgumentCompletions.set(key, lane);
}
return lane;
}
let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false);
@@ -260,7 +315,9 @@
const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(attachments.length > 0);
const draftHasAttachments = $derived(
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
);
const canSubmitDraft = $derived(
canDeliverComposerDraft({
delivery: "submit",
@@ -712,6 +769,17 @@
initialSnapshot?: { token: number; source: ConsoleDisplaySource },
) {
handleProtocolCommandEvent(payload);
if (payload.event === "rewind_applied") {
if (draft.segments.length === 0 && attachments.length === 0) {
composerInputElement?.restoreSegments(payload.data.input);
} else {
pushWorkspaceAlert(
"info",
"Rewind restored Session history but kept the non-empty Composer unchanged.",
{ id: controlAlertId, title: "Rewind" },
);
}
}
if (payload.event === "snapshot") {
pendingSubmissions = payload.data.session.pending_submissions;
} else if (payload.event === "segment_rotated") {
@@ -928,6 +996,7 @@
function discardAllAttachments(): void {
const discarded = attachments;
attachments = [];
uploadReservations.clear();
for (const attachment of discarded) {
attachment.request?.abort();
if (attachment.reference) {
@@ -944,31 +1013,50 @@
if (nextKey === activeComposerTargetKey) return;
if (activeComposerTargetKey) discardAllAttachments();
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
cachedComposerDraft(composerInputElement.snapshot()),
);
const cached = cachedComposerDraft(draft);
const pending = admissions.get(activeComposerTargetKey);
if (!pending || pending.status === "rejected") {
cached.segments = cached.segments.filter((segment) => segment.kind !== "uploaded_file" && segment.kind !== "unknown");
}
composerDrafts.set(activeComposerTargetKey, cached);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
syncAdmission();
const pending = admissions.get(nextKey);
const restored = pending && pending.status !== "rejected"
? cachedComposerDraft(pending.snapshot)
: composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(
restored.segments,
restored.preserveExactText,
true,
);
});
}
function composerSnapshotOrReport(): ComposerDraftSnapshot | null {
try {
return composerInputElement?.snapshot() ?? draft;
} catch (error) {
reportComposerError(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleComposerCommand() {
void submitDraft(composerInputElement?.snapshot() ?? draft);
const value = composerSnapshotOrReport();
if (value) void submitDraft(value);
}
function handleComposerSubmit() {
if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) {
const value = composerSnapshotOrReport();
if (!value) return;
if (value.document.trimStart().startsWith(":")) {
handleComposerCommand();
return;
}
@@ -976,15 +1064,17 @@
sendWorkerControl("cancel");
return;
}
void submitDraft(composerInputElement?.snapshot() ?? draft);
void submitDraft(value);
}
function handleQueueSubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "queue");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "queue");
}
function handleNotifySubmit() {
void submitDraft(composerInputElement?.snapshot() ?? draft, "notify");
const value = composerSnapshotOrReport();
if (value) void submitDraft(value, "notify");
}
function attachmentPath(): string {
@@ -1001,6 +1091,32 @@
);
}
function completeAttachmentUpload(
attachment: ComposerAttachment,
reference: NonNullable<ComposerAttachment["reference"]>,
): void {
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
!composerInputElement || !composerEditable || attachment.uploadPath !== attachmentPath()) {
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
void fetch(
`${attachment.uploadPath}/attachments/${encodeURIComponent(reference.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
return;
}
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
stagedAttachmentPaths.set(reference.artifact_id, attachment.uploadPath);
const segment: Segment = { kind: "uploaded_file", file: reference };
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation === undefined || !composerInputElement.completeUpload(reservation, segment)) {
void releaseComposerAtom(segment);
}
}
function startAttachmentUpload(attachment: ComposerAttachment): void {
const error = validateAttachmentFile(attachment.file);
if (error) {
@@ -1020,14 +1136,7 @@
attachment.uploadId,
{
progress: (progress) => updateAttachment(attachment.id, { progress }),
complete: (reference) =>
updateAttachment(attachment.id, {
state: "uploaded",
progress: 1,
reference,
error: null,
request: null,
}),
complete: (reference) => completeAttachmentUpload(attachment, reference),
failed: (message) =>
updateAttachment(attachment.id, {
state: "failed",
@@ -1044,7 +1153,8 @@
function addAttachmentFiles(files: Iterable<File>): void {
if (!composerEditable) return;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length);
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
for (const file of Array.from(files).slice(0, available)) {
const attachment: ComposerAttachment = {
id: nextAttachmentId++,
@@ -1057,13 +1167,30 @@
error: null,
request: null,
};
const reservation = composerInputElement?.reserveUpload(file.name);
if (reservation === null || reservation === undefined) continue;
uploadReservations.set(attachment.id, reservation);
attachments = [...attachments, attachment];
startAttachmentUpload(attachment);
}
}
async function releaseComposerAtom(segment: Segment): Promise<void> {
if (segment.kind !== "uploaded_file" || admissions.protects(segment)) return;
const path = stagedAttachmentPaths.get(segment.file.artifact_id);
if (!path) return; // Restored references are Session-owned, not client staging.
stagedAttachmentPaths.delete(segment.file.artifact_id);
await fetch(
`${path}/attachments/${encodeURIComponent(segment.file.artifact_id)}`,
{ method: "DELETE" },
).catch(() => undefined);
}
async function removeAttachment(attachment: ComposerAttachment): Promise<void> {
attachment.request?.abort();
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
if (attachment.reference) {
await fetch(`${attachment.uploadPath}/attachments/${encodeURIComponent(attachment.reference.artifact_id)}`, {
@@ -1106,8 +1233,13 @@
value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit",
) {
if (delivery === "notify" && attachments.length > 0) {
reportComposerError("Notify accepts text only; remove attachments or queue a Submit.");
if (
delivery === "notify" &&
(attachments.length > 0 || value.segments.some((segment) =>
segment.kind !== "text" && segment.kind !== "paste"
))
) {
reportComposerError("Notify accepts text only; remove attachments or Feature invocations or queue a Submit.");
return;
}
const incompleteAttachment = attachments.find((attachment) =>
@@ -1119,12 +1251,8 @@
: incompleteAttachment.error ?? "Retry or remove the failed attachment.");
return;
}
const attachmentSegments: Segment[] = attachments.map((attachment) => ({
kind: "uploaded_file",
file: attachment.reference!,
}));
const command = buildComposerSegmentsRequest(
[...value.segments, ...attachmentSegments],
value.segments,
{
preserveExactText: value.textPastes.length > 0,
});
@@ -1143,7 +1271,8 @@
protocolOpen: protocolState === "open",
sending,
hasText: value.content.trim().length > 0,
hasAttachments: attachments.length > 0,
hasAttachments: attachments.length > 0 ||
value.segments.some((segment) => segment.kind === "uploaded_file"),
};
// Commands are controls, not idle-only chat submissions. Keep the
// removed header controls available while the Worker is busy too.
@@ -1166,22 +1295,28 @@
}
sending = true;
try {
const method = composerRequestToProtocolMethod(request);
let method = composerRequestToProtocolMethod(request);
if (method.method === "submit" || method.method === "notify") {
// Store before send: even a synchronous acknowledgement must correlate.
const pending = admissions.begin(activeComposerTargetKey, method, value, delivery);
method = pending.method;
syncAdmission();
}
if (isCommand) {
sendProtocolMethod(method);
} else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
return;
}
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
attachments = [];
if (method.method === "submit" && delivery === "submit") {
liveWorkerState = "running";
if (method.method !== "submit" && method.method !== "notify") {
composerInputElement?.recordHistory(value);
composerInputElement?.clear();
}
// Submit/Notify retain the editor, history and upload leases until ack.
} catch (error) {
admissionDisconnected();
reportComposerError(error instanceof Error ? error.message : String(error));
} finally {
sending = false;
syncAdmission();
}
}
@@ -1229,6 +1364,7 @@
return (await response.json()) as WorkerSessionObservation;
})
.then((observation) => {
if (controller.signal.aborted || token !== reloadToken) return;
const currentTarget = consoleTarget;
if (!currentTarget) return;
const currentIdentity: WorkerSessionRequestIdentity = {
@@ -1362,6 +1498,9 @@
);
}
fileCompletions.reset();
featureCompletions.reset();
for (const lane of featureArgumentCompletions.values()) lane.reset();
featureArgumentCompletions.clear();
protocolState = "open";
} else if (
frame.frame === "event" &&
@@ -1416,7 +1555,7 @@
return () => {
if (protocolSubscription === subscription) {
protocolSubscription = null;
fileCompletions.close();
rejectPendingCompletion(new Error("Worker completion connection closed."), `${target.workspaceId}:${target.runtimeId}:${target.workerId}`);
}
subscription.close();
};
@@ -1430,8 +1569,42 @@
}
function handleProtocolCommandEvent(event: ProtocolEvent) {
const acknowledgement = admissions.acknowledge(activeComposerTargetKey, event);
if (acknowledgement) {
const { record, accepted, message } = acknowledgement;
if (accepted) {
// Only an authoritative acceptance transfers staging ownership.
for (const segment of record.snapshot.segments) {
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
}
composerInputElement?.recordHistory(record.snapshot);
// A late acceptance of a previously rejected attempt must not erase
// edits made while the recovered draft was unlocked.
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
composerInputElement?.clear(true);
attachments = [];
}
if (event.event === "submission_accepted" && event.data.disposition === "started") {
liveWorkerState = "running";
}
} else {
// The editor has stayed frozen with the exact typed input, Undo state
// and staged resources. Unlock it; unchanged retries reuse the IDs.
reportComposerError(message ?? "Worker rejected the input.");
}
syncAdmission();
return;
}
if (event.event === "completions") {
if (event.data.kind === "file") fileCompletions.receive(event.data.entries);
if (event.data.kind === "file") {
fileCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.kind === "feature") {
featureCompletions.receive(event.data.entries, event.data.prefix, event.data.request_id);
} else if (event.data.context) {
featureArgumentCompletions
.get(completionContextKey(event.data.context))
?.receive(event.data.entries, event.data.prefix, event.data.request_id);
}
return;
}
if (event.event === "rewind_targets") {
@@ -1448,7 +1621,10 @@
}
if (event.event === "error") {
const error = new Error(event.data.message);
if (fileCompletions.pending) {
if (
fileCompletions.pending || featureCompletions.pending ||
[...featureArgumentCompletions.values()].some((lane) => lane.pending)
) {
rejectPendingCompletion(error);
}
streamDiagnostics = [
@@ -1462,8 +1638,11 @@
}
}
function rejectPendingCompletion(error: Error) {
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey);
fileCompletions.close(error);
featureCompletions.close(error);
for (const lane of featureArgumentCompletions.values()) lane.close(error);
}
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
@@ -1633,7 +1812,13 @@
});
$effect(() => {
return () => discardAllAttachments();
return () => {
// Observation frames must not project or start history reads after
// this Console is gone. In-flight history must not enqueue a refresh.
cancelObservationFlush();
pendingHistoryRefreshes.clear();
discardAllAttachments();
};
});
$effect(() => {
@@ -1988,6 +2173,13 @@
onkeydown={handleComposerKeydown}
completionScope={activeComposerTargetKey}
resolveFileCompletions={(prefix, signal) => fileCompletions.request(prefix, signal)}
resolveFeatureCompletions={(prefix, signal) => featureCompletions.request(prefix, signal)}
resolveFeatureArgumentCompletions={(context, prefix, signal) =>
featureArgumentCompletionLane(context).request(prefix, signal)}
onremoveatom={(segment) => void releaseComposerAtom(segment)}
onclientadapter={(descriptor) => {
if (descriptor.client_adapter === "attachment") fileInput?.click();
}}
oncommand={handleComposerCommand}
onsubmit={handleComposerSubmit}
onpasteimages={addPastedImages}
@@ -2017,6 +2209,20 @@
{/if}
<div class="composer-input-footer">
<div class="composer-footer-slot">
{#if admission && admission.status !== "rejected"}
<button
class="composer-retry-button"
type="button"
aria-label="Retry admission"
title="Resend the same input and request ID; admission is not yet confirmed"
disabled={protocolState !== "open"}
onclick={retryAdmission}
>
<svg class="composer-retry-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M20 7V12H15M20 12A8 8 0 1 0 18 17" />
</svg>
</button>
{/if}
<button
class="composer-attach-button"
type="button"
@@ -2584,6 +2790,7 @@
.composer-attachment button,
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
border: 0;
@@ -2595,6 +2802,7 @@
}
.composer-attach-button,
.composer-retry-button,
.composer-queue-button,
.composer-notify-button {
display: inline-grid;
@@ -2607,6 +2815,7 @@
}
.composer-attach-button:hover:not(:disabled),
.composer-retry-button:hover:not(:disabled),
.composer-queue-button:hover:not(:disabled),
.composer-notify-button:hover:not(:disabled) {
background: var(--bg-subtle);
@@ -2614,6 +2823,7 @@
}
.composer-attach-button:disabled,
.composer-retry-button:disabled,
.composer-queue-button:disabled,
.composer-notify-button:disabled {
cursor: not-allowed;
@@ -2661,6 +2871,7 @@
}
.composer-attach-icon,
.composer-retry-icon,
.composer-queue-icon,
.composer-notify-icon,
.composer-send-icon {
@@ -11,11 +11,15 @@ import {
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import type {
Event as ProtocolEvent,
FeatureInvocationDescriptor,
Method,
SessionConversationTurn,
SessionSnapshot,
} from "#lib/generated/protocol.ts";
import type { Worker } from "#lib/workspace/sidebar/types.ts";
import { EditorView } from "@codemirror/view";
import * as attachmentsApi from "#lib/workspace/console/composer-attachments.ts";
import { undo } from "@codemirror/commands";
import * as alerts from "#lib/workspace/alerts/store.ts";
import ConsolePage from "./+page.svelte";
@@ -212,11 +216,276 @@ function latestListener() {
return listener;
}
function protocolEvent(event: ProtocolEvent) {
latestListener().onFrame({ frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { worker_id: "worker-a", event },
} } } });
}
async function settleMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
const runDescriptor: FeatureInvocationDescriptor = {
identity: "feature:test/run", name: "run", aliases: [], display_name: "Run", description: "Run",
syntax: "parenthesized", arguments: [{ name: "path", position: 0, required: true,
value_type: { kind: "worker_file" }, completion: { kind: "worker_file" } }],
};
async function liveComposer() {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({ availability: "live_protocol" }));
vi.stubGlobal("fetch", fetchMock);
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame());
await screen.findByText("No conversation to display");
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
const form = view.container.querySelector(".console-composer") as HTMLFormElement;
return { view, cm, form, fetchMock };
}
function completionRequest(): Extract<Method, { method: "list_completions" }> {
const method = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Method;
if (method.method !== "list_completions") throw new Error("Expected completion request");
return method;
}
function completionReply(method: Extract<Method, { method: "list_completions" }>, value: string, invocation?: FeatureInvocationDescriptor) {
protocolEvent({ event: "completions", data: { ...method.params, entries: [{ value, invocation, is_dir: false }] } });
}
async function selectRun(cm: EditorView) {
cm.focus();
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
completionReply(completionRequest(), "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a b/資料")' }, selection: { anchor: '/run("a b/資料")'.length } });
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
}
test("Console teardown cancels pending observation frames before they can read history", async () => {
const { view, fetchMock } = await liveComposer();
const schedule = vi.spyOn(window, "requestAnimationFrame");
const cancel = vi.spyOn(window, "cancelAnimationFrame");
const warn = vi.spyOn(console, "warn");
protocolEvent({ event: "user_message", data: { segments: [{ kind: "text", content: "pending at teardown" }] } });
const handle = schedule.mock.results.at(-1)!.value as number;
const requestsBeforeUnmount = fetchMock.mock.calls.length;
await view.unmount();
expect(cancel).toHaveBeenCalledWith(handle);
// Let the real happy-dom frame queue run; do not mock away the lifecycle bug.
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
expect(fetchMock).toHaveBeenCalledTimes(requestsBeforeUnmount);
expect(warn).not.toHaveBeenCalled();
});
test("Feature and argument completion correlate same-prefix ABA, context and other clients", async () => {
const { cm } = await liveComposer();
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "/ru" }, selection: { anchor: 3 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalled());
const old = completionRequest();
cm.dispatch({ changes: { from: 2, to: 3, insert: "x" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("rx"));
cm.dispatch({ changes: { from: 2, to: 3, insert: "u" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("ru"));
const current = completionRequest();
expect(current.params.request_id).not.toBe(old.params.request_id);
completionReply(old, "old", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: "another-client" } }, "foreign", runDescriptor);
completionReply({ ...current, params: { ...current.params, request_id: undefined } }, "legacy", runDescriptor);
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(current, "run", runDescriptor);
await screen.findByRole("option");
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
cm.dispatch({ changes: { from: cm.state.doc.length, insert: '"a' }, selection: { anchor: 7 } });
await waitFor(() => expect(completionRequest().params.context?.argument).toBe("path"));
const argOld = completionRequest();
cm.dispatch({ changes: { from: 6, to: 7, insert: "b" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("b"));
cm.dispatch({ changes: { from: 6, to: 7, insert: "a" } });
await waitFor(() => expect(completionRequest().params.prefix).toBe("a"));
const argCurrent = completionRequest();
completionReply(argOld, "stale-path");
completionReply({ ...argCurrent, params: { ...argCurrent.params, context: { invocation: "other-feature", argument: "path" } } }, "wrong-context");
await settleMicrotasks();
expect(screen.queryByRole("option")).toBeNull();
completionReply(argCurrent, "a b/資料");
expect((await screen.findByRole("option")).textContent).toContain("a b/資料");
});
test("Submit rejection keeps exact typed draft, request/invocation IDs, and only acceptance clears", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.method).toBe("submit");
expect(first.params.input[0]).toMatchObject({ kind: "feature_invoke", invocation: { identity: runDescriptor.identity, arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] } });
const doc = cm.state.doc.toString();
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
await fireEvent.keyDown(cm.contentDOM, { key: "z", ctrlKey: true });
expect(cm.state.doc.toString()).toBe(doc);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: "other-client", message: "foreign" } });
await settleMicrotasks();
expect(cm.state.readOnly).toBe(true);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "Feature validation failed" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(alert).toHaveBeenCalledWith("error", "Feature validation failed", expect.any(Object));
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "s", disposition: "started" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
expect(undo(cm)).toBe(false);
});
test("failed upload retry preserves middle insertion, Undo, unknown admission and accepted ownership", async () => {
const uploads: attachmentsApi.AttachmentUploadCallbacks[] = [];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
uploads.push(callbacks);
return { abort: vi.fn() };
});
const { cm, form, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "before after" }, selection: { anchor: 7 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "report.md", { type: "text/markdown" })] } });
uploads[0].failed("upload failed");
await fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
const reference = { artifact_id: "staged-artifact", file_name: "report.md", media_type: "text/markdown", created_at_ms: 1, availability: "available" as const, byte_len: 4, sha256: "abcd" };
uploads[1].complete(reference);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
cm.dispatch({ selection: { anchor: 10 } });
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(view.container.textContent).not.toContain("Attached report.md");
undo(cm);
await waitFor(() => expect(view.container.textContent).toContain("Attached report.md"));
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
expect(first.params.input).toEqual([{ kind: "text", content: "before " }, { kind: "uploaded_file", file: reference }, { kind: "text", content: " after" }]);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
latestListener().onStatus?.("connecting");
latestListener().onFrame(subscribedFrame());
await fireEvent.click(await screen.findByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "accepted", disposition: "queued" } });
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
view.unmount();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("late acceptance after rejection does not clear newer draft edits", async () => {
const { cm, form } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "original" }, selection: { anchor: 8 } });
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "try later" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "newer draft" }, selection: { anchor: 11 } });
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "old", disposition: "queued" } });
await settleMicrotasks();
expect(cm.state.doc.toString()).toBe("newer draft");
});
test("unmounting unknown upload admission does not delete possibly accepted artifacts", async () => {
let complete!: attachmentsApi.AttachmentUploadCallbacks["complete"];
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, callbacks) => {
complete = callbacks.complete;
return { abort: vi.fn() };
});
const { view, form, fetchMock } = await liveComposer();
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "unknown.md", { type: "text/markdown" })] } });
complete({ artifact_id: "unknown-artifact", file_name: "unknown.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await waitFor(() => expect(view.container.textContent).toContain("Attached unknown.md"));
await fireEvent.submit(form);
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
view.unmount();
await settleMicrotasks();
expect(fetchMock.mock.calls.some(([_path, init]) => init?.method === "DELETE")).toBe(false);
});
test("cached unsent typed draft survives target round-trip and remains sendable", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
await fireEvent.submit(form);
expect(alert).not.toHaveBeenCalledWith("error", expect.any(String), expect.any(Object));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({ method: "submit", params: { submission_request_id: expect.any(String), input: [expect.objectContaining({ kind: "feature_invoke" })] } });
});
test("unknown Submit survives target ABA and cannot be deleted into a new admission", async () => {
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
await waitFor(() => expect(cm.state.readOnly).toBe(true));
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
expect(cm.state.readOnly).toBe(true);
const doc = cm.state.doc.toString();
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
view.unmount();
});
test("cancelled attachment ignores late completion and deletes only abandoned staging", async () => {
let callbacks!: attachmentsApi.AttachmentUploadCallbacks;
const abort = vi.fn();
vi.spyOn(attachmentsApi, "uploadAttachment").mockImplementation((_path, _file, _id, value) => {
callbacks = value;
return { abort };
});
const { cm, view, fetchMock } = await liveComposer();
cm.dispatch({ changes: { from: 0, insert: "keep this" }, selection: { anchor: 4 } });
await fireEvent.change(view.container.querySelector('input[type="file"]')!, { target: { files: [new File(["data"], "late.md", { type: "text/markdown" })] } });
await fireEvent.click(screen.getByRole("button", { name: "Remove late.md" }));
expect(abort).toHaveBeenCalledOnce();
callbacks.complete({ artifact_id: "late-artifact", file_name: "late.md", media_type: "text/markdown", created_at_ms: 1, availability: "available", byte_len: 4, sha256: "abcd" });
await settleMicrotasks();
expect(view.container.textContent).not.toContain("Attached late.md");
expect(cm.state.doc.toString()).toBe("keep this");
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/attachments/late-artifact"), { method: "DELETE" });
undo(cm);
expect(cm.dom.textContent).not.toContain("Uploading late.md");
});
test.each(["notification_rejected", "notification_accepted"] as const)("Notify keeps text until authoritative %s", async (outcome) => {
const { cm } = await liveComposer();
protocolEvent({ event: "worker_state", data: { snapshot: { last_command_id: 0, state: { kind: "busy", state: { kind: "run", state: "running" } } } } });
cm.dispatch({ changes: { from: 0, insert: "通知" }, selection: { anchor: 2 } });
await fireEvent.click(await screen.findByRole("button", { name: "Notify Worker" }));
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "notify" }>;
expect(cm.state.doc.toString()).toBe("通知");
protocolEvent(outcome === "notification_accepted"
? { event: outcome, data: { notification_request_id: first.params.notification_request_id } }
: { event: outcome, data: { notification_request_id: first.params.notification_request_id, message: "not running" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(cm.state.doc.toString()).toBe(outcome === "notification_accepted" ? "" : "通知");
if (outcome === "notification_rejected") {
await fireEvent.click(screen.getByRole("button", { name: "Notify Worker" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
}
});
beforeEach(() => {
multiplexer.listeners.length = 0;
multiplexer.close.mockClear();
@@ -293,6 +562,11 @@ test.each([
expect(form.querySelector('[role="alert"]')).toBeNull();
expect(form.textContent).not.toContain(message);
if (transportFailure) {
const method = multiplexer.sendWorkerMethod.mock.calls[0][0];
protocolEvent({ event: "submission_rejected", data: { submission_request_id: method.params.submission_request_id, message } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
}
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":peer teammate" } });
await fireEvent.submit(form);
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
@@ -430,7 +704,7 @@ test.each(["idle", "running", "paused"] as const)("Composer completion executes
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
});
test("file completions consume stale responses before the latest prefix and close with the connection", async () => {
test("file completion nonces reject stale replies without blocking newer requests", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
@@ -439,23 +713,20 @@ test("file completions consume stale responses before the latest prefix and clos
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
cm.focus();
cm.dispatch({ changes: { from: 0, insert: "@old" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old" } }));
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old", request_id: expect.any(String) } }));
const old = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
cm.dispatch({ changes: { from: 1, to: 4, insert: "new" }, selection: { anchor: 4 } });
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new", request_id: expect.any(String) } }));
const current = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0].params.request_id;
const receive = (value: string, prefix: string, request_id?: string) => protocolEvent({ event: "completions", data: { kind: "file", prefix, request_id, entries: [{ value, is_dir: false }] } });
receive("old-result", "old", old);
await settleMicrotasks();
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledOnce();
const receive = (value: string) => latestListener().onFrame({
frame: "event", message: { event: "event", data: { payload: {
event: "worker_protocol", data: { event: { event: "completions", data: { kind: "file", entries: [{ value, is_dir: false }] } } },
} } },
});
receive("old-result");
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new" } }));
expect(screen.queryByRole("option")).toBeNull();
receive("new-result");
receive("new-result", "new", current);
expect((await screen.findByRole("option")).textContent).toContain("@new-result");
latestListener().onStatus?.("closed", { kind: "transport", message: "connection lost" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
receive("late-result");
receive("late-result", "new", current);
await settleMicrotasks();
expect(screen.queryByRole("listbox")).toBeNull();
});
@@ -199,3 +199,22 @@ Deno.test("Composer input uses boundary-aware Up and Down history navigation", a
assert(consoleSource.includes("historyScope={workspaceId}"));
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
});
Deno.test("declarative invocation history persists typed values without sharing mutable arguments", () => {
const value: ComposerHistoryEntry = { preserveExactText: false, segments: [{ kind: "feature_invoke", invocation: {
invocation_id: "invoke-1", identity: "test/run", name: "run", arguments: [
{ name: "path", value: { kind: "string", value: "資料/a b" } },
{ name: "count", value: { kind: "integer", value: 2 } },
{ name: "enabled", value: { kind: "boolean", value: true } },
],
} }] };
const history = new ComposerHistory([value]);
const storage = memoryStorage();
saveComposerHistory(storage, "workspace-1", history);
const expected = [{ segments: value.segments, preserveExactText: false }];
assertEquals(loadComposerHistory(storage, "workspace-1").entries, expected);
const copy = history.entries[0].segments[0];
assert(copy.kind === "feature_invoke");
copy.invocation.arguments[0].value = { kind: "string", value: "modified" };
assertEquals(history.entries, expected);
});