feat: add declarative Feature chat invocations and typed composers
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+257
-46
@@ -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 {
|
||||
|
||||
+283
-12
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user