feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -4,8 +4,13 @@ import {
|
||||
deriveAgentScreenViewState,
|
||||
type AgentScreenMachineInput,
|
||||
type AgentScreenMachineMemory,
|
||||
type AgentScreenViewState,
|
||||
} from "./use-agent-screen-state-machine";
|
||||
|
||||
type ReadyState = Extract<AgentScreenViewState, { tag: "ready" }>;
|
||||
type CatchingUpSyncState = Extract<ReadyState["sync"], { status: "catching_up" }>;
|
||||
type SyncErrorSyncState = Extract<ReadyState["sync"], { status: "sync_error" }>;
|
||||
|
||||
function createAgent(id: string): Agent {
|
||||
const now = new Date("2026-02-19T00:00:00.000Z");
|
||||
return {
|
||||
@@ -52,15 +57,49 @@ function createBaseInput(): AgentScreenMachineInput {
|
||||
isHistorySyncing: false,
|
||||
needsAuthoritativeSync: false,
|
||||
shouldUseOptimisticStream: false,
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseMemory(
|
||||
overrides: Partial<AgentScreenMachineMemory> = {}
|
||||
): AgentScreenMachineMemory {
|
||||
return {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
activeToastLatch: "none",
|
||||
hadInitialSyncFailure: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectReadyState(state: AgentScreenViewState): ReadyState {
|
||||
expect(state.tag).toBe("ready");
|
||||
if (state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function expectCatchingUpSync(state: ReadyState): CatchingUpSyncState {
|
||||
expect(state.sync.status).toBe("catching_up");
|
||||
if (state.sync.status !== "catching_up") {
|
||||
throw new Error("expected catching_up sync state");
|
||||
}
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
function expectSyncErrorSync(state: ReadyState): SyncErrorSyncState {
|
||||
expect(state.sync.status).toBe("sync_error");
|
||||
if (state.sync.status !== "sync_error") {
|
||||
throw new Error("expected sync_error sync state");
|
||||
}
|
||||
return state.sync;
|
||||
}
|
||||
|
||||
describe("deriveAgentScreenViewState", () => {
|
||||
it("returns boot loading before first interactive paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input = createBaseInput();
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
@@ -74,87 +113,174 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("stays ready after first paint even if agent is temporarily missing", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input = createBaseInput();
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("stale");
|
||||
expect(result.state.syncStatus).toBe("idle");
|
||||
expect(result.state.agent.id).toBe("agent-1");
|
||||
expect(ready.source).toBe("stale");
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("shows reconnecting sync status without blocking after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
isConnected: false,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.syncStatus).toBe("reconnecting");
|
||||
expect(ready.sync.status).toBe("reconnecting");
|
||||
});
|
||||
|
||||
it("shows non-blocking catching-up state after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
it("shows overlay catching-up state for first open while loading history", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.syncStatus).toBe("catching_up");
|
||||
expect(sync.ui).toBe("overlay");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("uses toast catching-up state for already-hydrated agents", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(sync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps sync errors non-blocking once the screen was ready", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers first-load sync failure and keeps catch-up overlay off after error clears", () => {
|
||||
const initialMemory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const errorInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const errorResult = deriveAgentScreenViewState({
|
||||
input: errorInput,
|
||||
memory: initialMemory,
|
||||
});
|
||||
const errorReady = expectReadyState(errorResult.state);
|
||||
const errorSync = expectSyncErrorSync(errorReady);
|
||||
|
||||
expect(errorSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
expect(errorResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
|
||||
const retryInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
missingAgentState: { kind: "idle" },
|
||||
};
|
||||
const retryResult = deriveAgentScreenViewState({
|
||||
input: retryInput,
|
||||
memory: errorResult.memory,
|
||||
});
|
||||
const retryReady = expectReadyState(retryResult.state);
|
||||
const retrySync = expectCatchingUpSync(retryReady);
|
||||
|
||||
expect(retrySync.ui).toBe("silent");
|
||||
expect(retrySync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
expect(retryResult.memory.hadInitialSyncFailure).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps ready with sync_error when refresh fails after first paint", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectSyncErrorSync(ready);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("stale");
|
||||
expect(result.state.syncStatus).toBe("sync_error");
|
||||
expect(result.state.agent.id).toBe("agent-1");
|
||||
expect(ready.source).toBe("stale");
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
expect(sync.shouldEmitSyncErrorToast).toBe(true);
|
||||
});
|
||||
|
||||
it("emits sync error toast only on transition into sync_error", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectSyncErrorSync(firstReady);
|
||||
expect(firstSync.shouldEmitSyncErrorToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectSyncErrorSync(secondReady);
|
||||
expect(secondSync.shouldEmitSyncErrorToast).toBe(false);
|
||||
});
|
||||
|
||||
it("returns blocking error before first paint when refresh fails", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "error", message: "network timeout" },
|
||||
@@ -170,10 +296,10 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("returns not_found when resolver confirms missing agent", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
};
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
missingAgentState: { kind: "not_found", message: "agent missing" },
|
||||
@@ -189,10 +315,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
});
|
||||
|
||||
it("promotes optimistic source while placeholder is used", () => {
|
||||
const memory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
placeholderAgent: createAgent("draft-agent"),
|
||||
@@ -200,19 +323,14 @@ describe("deriveAgentScreenViewState", () => {
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
|
||||
expect(result.state.tag).toBe("ready");
|
||||
if (result.state.tag !== "ready") {
|
||||
throw new Error("expected ready state");
|
||||
}
|
||||
expect(result.state.source).toBe("optimistic");
|
||||
expect(ready.source).toBe("optimistic");
|
||||
expect(ready.sync.status).toBe("idle");
|
||||
});
|
||||
|
||||
it("keeps optimistic flow non-blocking while transitioning to authoritative stream", () => {
|
||||
const initialMemory: AgentScreenMachineMemory = {
|
||||
hasRenderedReady: false,
|
||||
lastReadyAgent: null,
|
||||
};
|
||||
const initialMemory = createBaseMemory();
|
||||
const optimisticInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
placeholderAgent: createAgent("draft-agent"),
|
||||
@@ -223,11 +341,8 @@ describe("deriveAgentScreenViewState", () => {
|
||||
input: optimisticInput,
|
||||
memory: initialMemory,
|
||||
});
|
||||
expect(optimistic.state.tag).toBe("ready");
|
||||
if (optimistic.state.tag !== "ready") {
|
||||
throw new Error("expected optimistic ready state");
|
||||
}
|
||||
expect(optimistic.state.source).toBe("optimistic");
|
||||
const optimisticReady = expectReadyState(optimistic.state);
|
||||
expect(optimisticReady.source).toBe("optimistic");
|
||||
|
||||
const handoffInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
@@ -236,12 +351,94 @@ describe("deriveAgentScreenViewState", () => {
|
||||
input: handoffInput,
|
||||
memory: optimistic.memory,
|
||||
});
|
||||
const handoffReady = expectReadyState(handoff.state);
|
||||
|
||||
expect(handoff.state.tag).toBe("ready");
|
||||
if (handoff.state.tag !== "ready") {
|
||||
throw new Error("expected handoff ready state");
|
||||
}
|
||||
expect(handoff.state.source).toBe("stale");
|
||||
expect(handoff.state.agent.id).toBe("draft-agent");
|
||||
expect(handoffReady.source).toBe("stale");
|
||||
expect(handoffReady.agent.id).toBe("draft-agent");
|
||||
});
|
||||
|
||||
it("emits history refresh toast only on transition into toast catch-up state", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
needsAuthoritativeSync: true,
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
|
||||
const first = deriveAgentScreenViewState({ input, memory });
|
||||
const firstReady = expectReadyState(first.state);
|
||||
const firstSync = expectCatchingUpSync(firstReady);
|
||||
|
||||
expect(firstSync.ui).toBe("toast");
|
||||
expect(firstSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const second = deriveAgentScreenViewState({
|
||||
input,
|
||||
memory: first.memory,
|
||||
});
|
||||
const secondReady = expectReadyState(second.state);
|
||||
const secondSync = expectCatchingUpSync(secondReady);
|
||||
|
||||
expect(secondSync.ui).toBe("toast");
|
||||
expect(secondSync.shouldEmitHistoryRefreshToast).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms history refresh toast after leaving and re-entering catch-up", () => {
|
||||
const baseInput: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
hasHydratedHistoryBefore: true,
|
||||
};
|
||||
const initialMemory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
});
|
||||
|
||||
const firstCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: initialMemory,
|
||||
});
|
||||
const firstCatchingUpReady = expectReadyState(firstCatchingUp.state);
|
||||
const firstCatchingUpSync = expectCatchingUpSync(firstCatchingUpReady);
|
||||
expect(firstCatchingUpSync.ui).toBe("toast");
|
||||
expect(firstCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
|
||||
const idle = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: false },
|
||||
memory: firstCatchingUp.memory,
|
||||
});
|
||||
const idleReady = expectReadyState(idle.state);
|
||||
expect(idleReady.sync.status).toBe("idle");
|
||||
|
||||
const secondCatchingUp = deriveAgentScreenViewState({
|
||||
input: { ...baseInput, needsAuthoritativeSync: true },
|
||||
memory: idle.memory,
|
||||
});
|
||||
const secondCatchingUpReady = expectReadyState(secondCatchingUp.state);
|
||||
const secondCatchingUpSync = expectCatchingUpSync(secondCatchingUpReady);
|
||||
expect(secondCatchingUpSync.ui).toBe("toast");
|
||||
expect(secondCatchingUpSync.shouldEmitHistoryRefreshToast).toBe(true);
|
||||
});
|
||||
|
||||
it("clears initial sync failure memory after history is hydrated", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
lastReadyAgent: createAgent("agent-1"),
|
||||
hadInitialSyncFailure: true,
|
||||
});
|
||||
const input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
hasHydratedHistoryBefore: true,
|
||||
needsAuthoritativeSync: true,
|
||||
};
|
||||
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
const ready = expectReadyState(result.state);
|
||||
const sync = expectCatchingUpSync(ready);
|
||||
|
||||
expect(sync.ui).toBe("toast");
|
||||
expect(result.memory.hadInitialSyncFailure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user