feat: model Worker Console display states
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import type { ConsoleDisplayState } from "./console-display-state";
|
||||
|
||||
type Props = {
|
||||
state: ConsoleDisplayState;
|
||||
hasContent: boolean;
|
||||
hasUnfilteredContent: boolean;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
let { state, hasContent, hasUnfilteredContent, onRetry }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if state.kind === "loading"}
|
||||
<div class="console-display-state loading" role="status" aria-live="polite">
|
||||
<span class="loading-spinner" aria-hidden="true"></span>
|
||||
<span>
|
||||
<strong>Loading conversation</strong>
|
||||
{#if state.stage === "session"}
|
||||
<small>Checking Worker Session availability…</small>
|
||||
{:else}
|
||||
<small>Waiting for the initial conversation snapshot…</small>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if state.kind === "unavailable" || state.kind === "failed"}
|
||||
<div class="console-display-state unavailable" role="alert">
|
||||
<span>
|
||||
<strong>
|
||||
{state.kind === "unavailable"
|
||||
? "Conversation unavailable"
|
||||
: "Unable to load conversation"}
|
||||
</strong>
|
||||
<small>{state.reason}</small>
|
||||
</span>
|
||||
<button type="button" onclick={onRetry}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if state.kind === "stale"}
|
||||
<div class="console-display-state stale" role="status" aria-live="polite">
|
||||
<span>
|
||||
<strong>
|
||||
{state.phase === "reconnecting"
|
||||
? "Refreshing conversation"
|
||||
: "Conversation updates are unavailable"}
|
||||
</strong>
|
||||
<small>{state.reason} Previously loaded conversation is preserved.</small>
|
||||
</span>
|
||||
<button type="button" onclick={onRetry}>Retry</button>
|
||||
</div>
|
||||
{:else if state.source === "retained"}
|
||||
<div class="console-display-state retained" role="status">
|
||||
<span>
|
||||
<strong>Read-only retained conversation</strong>
|
||||
<small>This saved Session snapshot cannot receive live updates.</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.kind === "ready" && !hasContent}
|
||||
<div class="console-display-state empty" role="status">
|
||||
<span>
|
||||
<strong>No conversation to display</strong>
|
||||
<small>
|
||||
{hasUnfilteredContent
|
||||
? "No console items match the current display mode."
|
||||
: "This Worker view has no conversation history yet."}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.console-display-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: var(--space-4);
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--bg-raised) 78%, transparent);
|
||||
}
|
||||
|
||||
.console-display-state > span {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.console-display-state strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.console-display-state small {
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.console-display-state button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--bg);
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.console-display-state.loading {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.console-display-state.unavailable {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
||||
}
|
||||
|
||||
.console-display-state.stale {
|
||||
border-color: color-mix(in srgb, var(--tui-yellow) 48%, var(--line));
|
||||
}
|
||||
|
||||
.console-display-state.retained {
|
||||
border-color: color-mix(in srgb, var(--tui-cyan) 42%, var(--line));
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid color-mix(in srgb, var(--accent) 30%, var(--line));
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.loading-spinner {
|
||||
animation: console-loading-spin 800ms linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes console-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
export type ConsoleDisplaySource = "live" | "retained";
|
||||
|
||||
export type ConsoleDisplayState =
|
||||
| { kind: "loading"; stage: "session" | "snapshot" }
|
||||
| { kind: "ready"; source: ConsoleDisplaySource }
|
||||
| { kind: "unavailable"; reason: string }
|
||||
| { kind: "failed"; reason: string }
|
||||
| {
|
||||
kind: "stale";
|
||||
source: ConsoleDisplaySource;
|
||||
phase: "reconnecting" | "disconnected";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const MAX_CONSOLE_REASON_LENGTH = 240;
|
||||
|
||||
export function boundedConsoleReason(
|
||||
reason: string | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
const normalized = reason?.replace(/\s+/g, " ").trim() || fallback;
|
||||
return normalized.slice(0, MAX_CONSOLE_REASON_LENGTH);
|
||||
}
|
||||
|
||||
export function displayedConsoleSource(
|
||||
state: ConsoleDisplayState,
|
||||
): ConsoleDisplaySource | null {
|
||||
switch (state.kind) {
|
||||
case "ready":
|
||||
case "stale":
|
||||
return state.source;
|
||||
case "loading":
|
||||
case "unavailable":
|
||||
case "failed":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -555,6 +555,56 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console models availability separately from projected lines", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const displayState = await Deno.readTextFile(
|
||||
new URL("./ConsoleDisplayState.svelte", import.meta.url),
|
||||
);
|
||||
const displayModel = await Deno.readTextFile(
|
||||
new URL("./console-display-state.ts", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consolePage.includes("consoleDisplayState") &&
|
||||
consolePage.includes("pendingInitialSnapshotApplication") &&
|
||||
consolePage.includes('source: "retained"') &&
|
||||
consolePage.includes('source: "live"') &&
|
||||
consolePage.includes("completeInitialSnapshot(") &&
|
||||
consolePage.includes("showConsoleUnavailable(") &&
|
||||
consolePage.includes("retryConsoleLoad") &&
|
||||
!consolePage.includes(
|
||||
"{#if lines.length === 0}\n <p>No console output",
|
||||
),
|
||||
"Console availability must advance on snapshot projection rather than infer loading, failure, or empty history from line count",
|
||||
);
|
||||
assert(
|
||||
displayModel.includes('kind: "loading"') &&
|
||||
displayModel.includes('kind: "ready"') &&
|
||||
displayModel.includes('kind: "unavailable"') &&
|
||||
displayModel.includes('kind: "failed"') &&
|
||||
displayModel.includes('kind: "stale"') &&
|
||||
displayModel.includes("MAX_CONSOLE_REASON_LENGTH = 240"),
|
||||
"Console display state must explicitly model loading, ready, unavailable, failed, and stale outcomes with bounded reasons",
|
||||
);
|
||||
assert(
|
||||
displayState.includes('role="status"') &&
|
||||
displayState.includes('role="alert"') &&
|
||||
displayState.includes('aria-live="polite"') &&
|
||||
displayState.includes("prefers-reduced-motion: no-preference") &&
|
||||
displayState.includes("Read-only retained conversation") &&
|
||||
displayState.includes(
|
||||
"No console items match the current display mode.",
|
||||
) &&
|
||||
displayState.includes(">Retry</button>"),
|
||||
"Console state presentation must expose accessible loading/errors, retained and filtered-empty copy, retry, and reduced-motion-safe animation",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console owns its narrower centered shell width", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
|
||||
+176
-12
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack, type SvelteComponent } from "svelte";
|
||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
|
||||
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
|
||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
||||
import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
|
||||
@@ -50,6 +51,12 @@
|
||||
type ComposerAttachment,
|
||||
} from "$lib/workspace/console/composer-attachments";
|
||||
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||
import {
|
||||
boundedConsoleReason,
|
||||
displayedConsoleSource,
|
||||
type ConsoleDisplaySource,
|
||||
type ConsoleDisplayState,
|
||||
} from "$lib/workspace/console/console-display-state";
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||
import {
|
||||
@@ -165,6 +172,10 @@
|
||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
let consoleDisplayState = $state<ConsoleDisplayState>({
|
||||
kind: "loading",
|
||||
stage: "session",
|
||||
});
|
||||
let protocolSubscription: WorkspaceMultiplexerSubscription | null = null;
|
||||
let pendingSubmissions = $state<PendingSubmissionsSnapshot>({
|
||||
revision: 0,
|
||||
@@ -209,6 +220,11 @@
|
||||
consoleProjector.snapshot(),
|
||||
);
|
||||
let pendingObservationEvents: ConsoleEventInput[] = [];
|
||||
let pendingInitialSnapshotApplication: {
|
||||
eventId: string;
|
||||
token: number;
|
||||
source: ConsoleDisplaySource;
|
||||
} | null = null;
|
||||
let protocolEventSequence = 0;
|
||||
let pendingStreamDiagnostics: Diagnostic[] = [];
|
||||
let observationFlushHandle: number | null = null;
|
||||
@@ -319,6 +335,79 @@
|
||||
return nextReloadToken;
|
||||
}
|
||||
|
||||
function beginConsoleLoad(reason = "Waiting for a fresh conversation snapshot.") {
|
||||
const source = displayedConsoleSource(consoleDisplayState);
|
||||
consoleDisplayState = source
|
||||
? {
|
||||
kind: "stale",
|
||||
source,
|
||||
phase: "reconnecting",
|
||||
reason: boundedConsoleReason(reason, "Reconnecting to live updates."),
|
||||
}
|
||||
: { kind: "loading", stage: "session" };
|
||||
protocolState = "connecting";
|
||||
}
|
||||
|
||||
function waitForInitialSnapshot() {
|
||||
if (displayedConsoleSource(consoleDisplayState) === null) {
|
||||
consoleDisplayState = { kind: "loading", stage: "snapshot" };
|
||||
}
|
||||
}
|
||||
|
||||
function completeInitialSnapshot(
|
||||
token: number,
|
||||
source: ConsoleDisplaySource,
|
||||
) {
|
||||
if (token !== reloadToken) return;
|
||||
consoleDisplayState = { kind: "ready", source };
|
||||
}
|
||||
|
||||
function showConsoleUnavailable(message: string) {
|
||||
const reason = boundedConsoleReason(
|
||||
message,
|
||||
"The Worker Session is not available.",
|
||||
);
|
||||
const source = displayedConsoleSource(consoleDisplayState);
|
||||
consoleDisplayState = source
|
||||
? { kind: "stale", source, phase: "disconnected", reason }
|
||||
: { kind: "unavailable", reason };
|
||||
}
|
||||
|
||||
function showConsoleFailure(message?: string) {
|
||||
const reason = boundedConsoleReason(
|
||||
message,
|
||||
"The Worker Session connection ended before a snapshot was received.",
|
||||
);
|
||||
const source = displayedConsoleSource(consoleDisplayState);
|
||||
consoleDisplayState = source
|
||||
? { kind: "stale", source, phase: "disconnected", reason }
|
||||
: { kind: "failed", reason };
|
||||
}
|
||||
|
||||
function showConsoleReconnecting(message?: string) {
|
||||
const source = displayedConsoleSource(consoleDisplayState);
|
||||
if (!source) return;
|
||||
consoleDisplayState = {
|
||||
kind: "stale",
|
||||
source,
|
||||
phase: "reconnecting",
|
||||
reason: boundedConsoleReason(
|
||||
message,
|
||||
"Waiting for a fresh conversation snapshot.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function retryConsoleLoad() {
|
||||
if (displayedConsoleSource(consoleDisplayState) === null) {
|
||||
resetObservedEvents();
|
||||
}
|
||||
const target = consoleTarget;
|
||||
beginConsoleLoad();
|
||||
const token = advanceReloadToken();
|
||||
if (!worker) void loadWorker(target, token);
|
||||
}
|
||||
|
||||
function advanceEventObservedAtVersion() {
|
||||
nextEventObservedAtVersion += 1;
|
||||
eventObservedAtVersion = nextEventObservedAtVersion;
|
||||
@@ -337,6 +426,7 @@
|
||||
observationFlushHandle = null;
|
||||
}
|
||||
pendingObservationEvents = [];
|
||||
pendingInitialSnapshotApplication = null;
|
||||
pendingStreamDiagnostics = [];
|
||||
}
|
||||
|
||||
@@ -352,8 +442,10 @@
|
||||
function flushObservationBatch() {
|
||||
observationFlushHandle = null;
|
||||
const eventBatch = pendingObservationEvents;
|
||||
const initialSnapshotApplication = pendingInitialSnapshotApplication;
|
||||
const diagnosticBatch = pendingStreamDiagnostics;
|
||||
pendingObservationEvents = [];
|
||||
pendingInitialSnapshotApplication = null;
|
||||
pendingStreamDiagnostics = [];
|
||||
|
||||
if (eventBatch.length > 0) {
|
||||
@@ -362,6 +454,17 @@
|
||||
? "shutdown"
|
||||
: workerStateFromSnapshot(consoleProjection.workerState);
|
||||
advanceEventObservedAtVersion();
|
||||
if (
|
||||
initialSnapshotApplication &&
|
||||
eventBatch.some(
|
||||
(event) => event.eventId === initialSnapshotApplication.eventId,
|
||||
)
|
||||
) {
|
||||
completeInitialSnapshot(
|
||||
initialSnapshotApplication.token,
|
||||
initialSnapshotApplication.source,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (diagnosticBatch.length > 0) {
|
||||
@@ -369,7 +472,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleIncomingProtocolEvent(payload: ProtocolEvent) {
|
||||
function handleIncomingProtocolEvent(
|
||||
payload: ProtocolEvent,
|
||||
initialSnapshot?: { token: number; source: ConsoleDisplaySource },
|
||||
) {
|
||||
handleProtocolCommandEvent(payload);
|
||||
if (payload.event === "snapshot") {
|
||||
pendingSubmissions = payload.data.session.pending_submissions;
|
||||
@@ -397,6 +503,13 @@
|
||||
event: payload,
|
||||
observedAtMs,
|
||||
});
|
||||
if (initialSnapshot && payload.event === "snapshot") {
|
||||
pendingInitialSnapshotApplication = {
|
||||
eventId,
|
||||
token: initialSnapshot.token,
|
||||
source: initialSnapshot.source,
|
||||
};
|
||||
}
|
||||
scheduleObservationFlush();
|
||||
}
|
||||
|
||||
@@ -960,6 +1073,7 @@
|
||||
const action = workerSessionAction(observation);
|
||||
if (action.kind === "show_unavailable") {
|
||||
protocolState = "closed";
|
||||
showConsoleUnavailable(action.message);
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
@@ -976,18 +1090,21 @@
|
||||
targetWorker.state === "stopped"
|
||||
) {
|
||||
protocolState = "closed";
|
||||
const message =
|
||||
"The Session is live but the Worker is stopped. Reload to refresh its state.";
|
||||
showConsoleFailure(message);
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_session_state_changed",
|
||||
severity: "warning",
|
||||
message:
|
||||
"The Session is live but the Worker is stopped. Reload to refresh its state.",
|
||||
message,
|
||||
},
|
||||
];
|
||||
return;
|
||||
}
|
||||
if (action.kind === "subscribe_live") {
|
||||
waitForInitialSnapshot();
|
||||
closeLiveSubscription = connectLiveProtocolTransport(token, target);
|
||||
return;
|
||||
}
|
||||
@@ -1009,7 +1126,7 @@
|
||||
in_flight: { responses: [], commands: [] },
|
||||
internal_workers: [],
|
||||
},
|
||||
} as ProtocolEvent);
|
||||
} as ProtocolEvent, { token, source: "retained" });
|
||||
protocolState = "closed";
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
@@ -1023,12 +1140,14 @@
|
||||
.catch((error) => {
|
||||
if (token !== reloadToken || controller.signal.aborted) return;
|
||||
protocolState = "error";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
showConsoleFailure(message);
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_session_observation_failed",
|
||||
severity: "warning",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -1055,8 +1174,22 @@
|
||||
frame.message.result === "subscribed" &&
|
||||
frame.message.payload.snapshot.topic === "worker_protocol"
|
||||
) {
|
||||
let initialSnapshotQueued = false;
|
||||
for (const event of frame.message.payload.snapshot.data.events) {
|
||||
handleIncomingProtocolEvent(event);
|
||||
if (!initialSnapshotQueued && event.event === "snapshot") {
|
||||
handleIncomingProtocolEvent(event, {
|
||||
token,
|
||||
source: "live",
|
||||
});
|
||||
initialSnapshotQueued = true;
|
||||
} else {
|
||||
handleIncomingProtocolEvent(event);
|
||||
}
|
||||
}
|
||||
if (!initialSnapshotQueued) {
|
||||
throw new Error(
|
||||
"The live subscription did not provide an initial snapshot.",
|
||||
);
|
||||
}
|
||||
protocolState = "open";
|
||||
} else if (
|
||||
@@ -1070,6 +1203,7 @@
|
||||
frame.message.event === "subscription_closed"
|
||||
) {
|
||||
protocolState = "closed";
|
||||
showConsoleFailure(frame.message.data.message);
|
||||
rejectPendingCompletion(new Error(frame.message.data.message));
|
||||
} else if (
|
||||
frame.frame === "response" &&
|
||||
@@ -1079,20 +1213,29 @@
|
||||
throw new Error(frame.message.payload.message);
|
||||
}
|
||||
} catch (error) {
|
||||
protocolState = "error";
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
showConsoleFailure(message);
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_protocol_frame_invalid",
|
||||
severity: "warning",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
onStatus: (status, message) => {
|
||||
if (token !== reloadToken) return;
|
||||
protocolState = status === "open" ? "connecting" : status;
|
||||
if (status === "connecting" || status === "open") {
|
||||
showConsoleReconnecting(message);
|
||||
}
|
||||
if (status === "closed") {
|
||||
showConsoleFailure(message);
|
||||
rejectPendingCompletion(new Error("Worker protocol WebSocket closed."));
|
||||
}
|
||||
},
|
||||
@@ -1697,6 +1840,7 @@
|
||||
liveWorkerState = targetWorker?.state ?? null;
|
||||
streamDiagnostics = [];
|
||||
protocolState = "connecting";
|
||||
consoleDisplayState = { kind: "loading", stage: "session" };
|
||||
const token = advanceReloadToken();
|
||||
if (!targetWorker) void loadWorker(target, token);
|
||||
});
|
||||
@@ -1705,7 +1849,19 @@
|
||||
return () => discardAllAttachments();
|
||||
});
|
||||
|
||||
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
|
||||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
const targetWorker = worker;
|
||||
const token = reloadToken;
|
||||
if (
|
||||
targetWorker &&
|
||||
(targetWorker.runtime_id !== target.runtimeId ||
|
||||
targetWorker.worker_id !== target.workerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return connectProtocolTransport(targetWorker, token, target);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWorkerControlShortcut} />
|
||||
@@ -1836,9 +1992,14 @@
|
||||
<p class="error">{workerError}</p>
|
||||
{/if}
|
||||
|
||||
{#if lines.length === 0}
|
||||
<p>No console output is available for this Worker yet.</p>
|
||||
{:else}
|
||||
<ConsoleDisplayStateView
|
||||
state={consoleDisplayState}
|
||||
hasContent={lines.length > 0}
|
||||
hasUnfilteredContent={selectedConsoleProjection.lines.length > 0}
|
||||
onRetry={retryConsoleLoad}
|
||||
/>
|
||||
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||
<ol class="console-log">
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} />
|
||||
@@ -2182,6 +2343,9 @@
|
||||
}
|
||||
|
||||
.console-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-4);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
Event as ProtocolEvent,
|
||||
SessionSnapshot,
|
||||
} from "$lib/generated/protocol";
|
||||
import type { Worker } from "$lib/workspace/sidebar/types";
|
||||
import ConsolePage from "./+page.svelte";
|
||||
|
||||
const multiplexer = vi.hoisted(() => {
|
||||
type Listener = {
|
||||
onFrame(frame: unknown): void;
|
||||
onStatus?(status: "connecting" | "open" | "closed", message?: string): void;
|
||||
};
|
||||
const listeners: Listener[] = [];
|
||||
return {
|
||||
listeners,
|
||||
close: vi.fn(),
|
||||
sendWorkerMethod: vi.fn(),
|
||||
subscribe: vi.fn((_selector: unknown, listener: Listener) => {
|
||||
listeners.push(listener);
|
||||
listener.onStatus?.("connecting");
|
||||
return {
|
||||
close: multiplexer.close,
|
||||
sendWorkerMethod: multiplexer.sendWorkerMethod,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/workspace/multiplexer", () => ({
|
||||
workspaceMultiplexer: () => ({ subscribe: multiplexer.subscribe }),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function worker(workerId = "worker-a", runtimeId = "runtime-a"): Worker {
|
||||
return {
|
||||
worker_id: workerId,
|
||||
runtime_id: runtimeId,
|
||||
resource_key: `worker:${workerId}`,
|
||||
display_name: workerId,
|
||||
label: workerId,
|
||||
state: "idle",
|
||||
host_id: "host-a",
|
||||
profile: "default",
|
||||
tags: [],
|
||||
diagnostics: [],
|
||||
capabilities: {
|
||||
can_stop: true,
|
||||
can_spawn_followup: true,
|
||||
},
|
||||
workspace: { visibility: "workspace", identity: "workspace-a" },
|
||||
implementation: { kind: "builtin", display_hint: "test" },
|
||||
workdir_attachments: [],
|
||||
} as unknown as Worker;
|
||||
}
|
||||
|
||||
function pageData(workerId = "worker-a", runtimeId = "runtime-a") {
|
||||
return {
|
||||
workspaceId: "workspace-a",
|
||||
runtimeId,
|
||||
workerId,
|
||||
worker: worker(workerId, runtimeId),
|
||||
workerError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function emptySession(): SessionSnapshot {
|
||||
return {
|
||||
pending_submissions: {
|
||||
revision: 0,
|
||||
notification_count: 0,
|
||||
head_id: null,
|
||||
submissions: [],
|
||||
},
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
|
||||
function sessionWithUserMessage(content: string): SessionSnapshot {
|
||||
return {
|
||||
...emptySession(),
|
||||
entries: [{
|
||||
kind: "user_input",
|
||||
segments: [{ kind: "text", content }],
|
||||
entry_id: `entry-${content}`,
|
||||
timestamp: 1,
|
||||
provenance: "human_input",
|
||||
derived_from: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotEvent(session = emptySession()): ProtocolEvent {
|
||||
return {
|
||||
event: "snapshot",
|
||||
data: {
|
||||
session,
|
||||
greeting: {
|
||||
worker_name: "worker-a",
|
||||
cwd: "",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
scope_summary: "test",
|
||||
tools: [],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
state: { last_command_id: 0, state: { kind: "idle" } },
|
||||
in_flight: { blocks: [], commands: [], compaction: null },
|
||||
internal_workers: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function subscribedFrame(session = emptySession()) {
|
||||
return {
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
snapshot: {
|
||||
topic: "worker_protocol",
|
||||
data: { events: [snapshotEvent(session)] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function latestListener() {
|
||||
const listener = multiplexer.listeners.at(-1);
|
||||
if (!listener) throw new Error("missing Worker protocol listener");
|
||||
return listener;
|
||||
}
|
||||
|
||||
async function settleMicrotasks() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
multiplexer.listeners.length = 0;
|
||||
multiplexer.close.mockClear();
|
||||
multiplexer.sendWorkerMethod.mockClear();
|
||||
multiplexer.subscribe.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("keeps delayed Session and live initial snapshot work in loading state until projection applies", async () => {
|
||||
const sessionResponse = deferred<Response>();
|
||||
vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise));
|
||||
render(ConsolePage, { data: pageData() });
|
||||
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
|
||||
sessionResponse.resolve(Response.json({ availability: "live_protocol" }));
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Waiting for the initial conversation snapshot",
|
||||
);
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
|
||||
latestListener().onFrame(subscribedFrame(emptySession()));
|
||||
expect(await screen.findByText("No conversation to display")).not.toBeNull();
|
||||
expect(screen.getByText("This Worker view has no conversation history yet."))
|
||||
.not
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("applies retained empty and nonempty snapshots before showing their read-only result", async () => {
|
||||
const response = deferred<Response>();
|
||||
const nextResponse = deferred<Response>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const pending = String(input).includes("worker-b")
|
||||
? nextResponse.promise
|
||||
: response.promise;
|
||||
return pending.then((value) => value.clone());
|
||||
}),
|
||||
);
|
||||
const view = render(ConsolePage, { data: pageData() });
|
||||
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
response.resolve(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}));
|
||||
expect(await screen.findByText("Read-only retained conversation")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("No conversation to display")).not.toBeNull();
|
||||
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
nextResponse.resolve(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: sessionWithUserMessage("retained message"),
|
||||
}));
|
||||
|
||||
expect(await screen.findByText("retained message")).not.toBeNull();
|
||||
expect(screen.getByText("Read-only retained conversation")).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
});
|
||||
|
||||
test("shows bounded initial failures and retries explicitly", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
||||
.mockResolvedValueOnce(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: emptySession(),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(ConsolePage, { data: pageData() });
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("Unable to load conversation");
|
||||
expect(alert.textContent).toContain("failed to observe Worker Session (503)");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByText("No conversation to display")).not.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("distinguishes typed unavailability and an initial live connection closure", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({
|
||||
availability: "unavailable",
|
||||
message: "retained state expired",
|
||||
}))
|
||||
.mockResolvedValueOnce(Response.json({ availability: "live_protocol" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
render(ConsolePage, { data: pageData() });
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"Conversation unavailable retained state expired",
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onStatus?.("closed", "subscription ended before snapshot");
|
||||
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"subscription ended before snapshot",
|
||||
);
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves rendered content while reconnecting", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve(Response.json({ availability: "live_protocol" }))
|
||||
),
|
||||
);
|
||||
render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("kept message")),
|
||||
);
|
||||
expect(await screen.findByText("kept message")).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "Workspace subscription disconnected");
|
||||
expect(await screen.findByText("Conversation updates are unavailable")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
expect(screen.queryByText("No conversation to display")).toBeNull();
|
||||
|
||||
latestListener().onStatus?.("connecting", "resubscribing");
|
||||
expect(await screen.findByText("Refreshing conversation")).not.toBeNull();
|
||||
expect(screen.getByText("kept message")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("route changes clear prior content and fence stale Session responses", async () => {
|
||||
const staleReload = deferred<Response>();
|
||||
const current = deferred<Response>();
|
||||
let firstWorkerLoads = 0;
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
if (String(input).includes("worker-b")) {
|
||||
return current.promise.then((value) => value.clone());
|
||||
}
|
||||
firstWorkerLoads += 1;
|
||||
if (firstWorkerLoads === 1) {
|
||||
return Promise.resolve(Response.json({ availability: "live_protocol" }));
|
||||
}
|
||||
return staleReload.promise.then((value) => value.clone());
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const view = render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(
|
||||
subscribedFrame(sessionWithUserMessage("previous worker content")),
|
||||
);
|
||||
expect(await screen.findByText("previous worker content")).not.toBeNull();
|
||||
|
||||
latestListener().onStatus?.("closed", "reload old worker");
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Retry" }),
|
||||
);
|
||||
await waitFor(() => expect(firstWorkerLoads).toBe(2));
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain(
|
||||
"Loading conversation",
|
||||
);
|
||||
|
||||
current.resolve(Response.json({
|
||||
availability: "retained_snapshot",
|
||||
snapshot: sessionWithUserMessage("current worker content"),
|
||||
}));
|
||||
expect(await screen.findByText("current worker content")).not.toBeNull();
|
||||
|
||||
staleReload.resolve(Response.json({
|
||||
availability: "unavailable",
|
||||
message: "stale worker error",
|
||||
}));
|
||||
await settleMicrotasks();
|
||||
expect(screen.queryByText("stale worker error")).toBeNull();
|
||||
expect(screen.queryByText("previous worker content")).toBeNull();
|
||||
expect(screen.getByText("current worker content")).not.toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user