Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions desktop/src/features/messages/lib/remoteAgentSession.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { test } from "node:test";
import {
publishedChannelMessage,
publishedRemoteSession,
remoteSessionOffersCheckpoint,
remoteSessionRecoveryKind,
usesThreadSandbox,
} from "./remoteAgentSession.ts";

Expand Down Expand Up @@ -83,3 +85,94 @@ test("only an explicitly configured Kubernetes Sandbox uses thread launches", ()
);
assert.equal(usesThreadSandbox({ backend: { type: "local" } }), false);
});

// Every lifecycle refusal the Kubernetes provider returns (sandbox.rs), plus
// errors that must not offer recovery.
const providerErrors = [
[
"Sandbox session ended (stopped); explicit recovery is required",
"recover",
true,
],
["Sandbox session ended; explicit recovery is required", "recover", true],
[
"Sandbox Pod was replaced; credentials remain bound to the original session, explicit recovery is required",
"recover",
true,
],
[
"Sandbox is not an active managed session; explicit recovery is required",
"recover",
true,
],
[
"Sandbox session is ending; recover it explicitly once it has ended",
"ending",
true,
],
[
"this session has no verified checkpoint; start fresh instead",
"recover",
false,
],
[
"no ended session exists for this thread to recover; start fresh instead",
"recover",
false,
],
[
"recovery requires an ended session; this Sandbox is still active, so end it first",
"end-first",
true,
],
[
"Sandbox launch configuration differs; end the session, then explicit recovery is required",
"end-first",
true,
],
[
"Sandbox belongs to a different relay; use a new thread or explicit recovery",
null,
true,
],
[
"agent already has a Pod; stop and recover it explicitly before using Sandbox",
null,
true,
],
["provider timed out after 600s", null, true],
["", null, true],
];

test("provider lifecycle errors map to the explicit action they ask for", () => {
for (const [error, kind, offersCheckpoint] of providerErrors) {
assert.equal(remoteSessionRecoveryKind(error), kind, error);
assert.equal(
remoteSessionRecoveryKind(error.toUpperCase()),
kind,
`case-insensitive: ${error}`,
);
assert.equal(remoteSessionOffersCheckpoint(error), offersCheckpoint, error);
}
});

test("provider codes override prose and survive Tauri error wrapping", () => {
for (const [code, kind, checkpoint] of [
["session_active", "end-first", true],
["config_differs", "end-first", true],
["session_ending", "ending", true],
["session_ended", "recover", true],
["no_checkpoint", "recover", false],
["no_ended_session", "recover", false],
["future_refusal", null, true],
]) {
const payload = { code, message: "session ended; start fresh instead" };
for (const error of [
payload,
Object.assign(new Error(payload.message), { payload }),
]) {
assert.equal(remoteSessionRecoveryKind(error), kind, code);
assert.equal(remoteSessionOffersCheckpoint(error), checkpoint, code);
}
}
});
78 changes: 78 additions & 0 deletions desktop/src/features/messages/lib/remoteAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,81 @@ export function publishedRemoteSession(
if (!/^[0-9a-f]{64}$/.test(threadRoot)) return undefined;
return { channelId, threadRoot };
}

/** The explicit action a thread-session error asks the user for. */
export type RemoteSessionRecoveryKind = "recover" | "end-first" | "ending";

/** Preserve codes carried by both raw provider failures and Tauri errors. */
export function remoteSessionErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
if ("payload" in error) return remoteSessionErrorCode(error.payload);
return "code" in error && typeof error.code === "string"
? error.code
: undefined;
}

function errorMessage(error: unknown): string {
if (typeof error === "string") return error.toLowerCase();
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof error.message === "string"
)
return error.message.toLowerCase();
return "";
}

/** Codes take precedence; phrase matching is only for older providers. */
export function remoteSessionRecoveryKind(
error: unknown,
): RemoteSessionRecoveryKind | null {
const code = remoteSessionErrorCode(error);
if (code !== undefined) {
switch (code) {
case "session_active":
case "config_differs":
return "end-first";
case "session_ending":
return "ending";
case "session_ended":
case "pod_ended":
case "pod_replaced":
case "session_not_running":
case "binding_expired":
case "recovery_interrupted":
case "no_checkpoint":
case "no_ended_session":
return "recover";
default:
return null;
}
}
const message = errorMessage(error);
if (message.includes("end it first") || message.includes("end the session"))
return "end-first";
if (
message.includes("session is ending") ||
message.includes("session is still ending")
)
return "ending";
if (
message.includes("explicit recovery is required") ||
message.includes("recover it explicitly once it has ended") ||
message.includes("session ended") ||
message.includes("start fresh instead")
)
return "recover";
return null;
}

export function remoteSessionOffersCheckpoint(error: unknown): boolean {
const code = remoteSessionErrorCode(error);
if (code !== undefined)
return code !== "no_checkpoint" && code !== "no_ended_session";
const message = errorMessage(error);
return !(
message.includes("no verified checkpoint") ||
message.includes("no ended session exists")
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
clearRemoteSessionRecoveryPrompt,
getRemoteSessionRecoveryPrompts,
MAX_REMOTE_SESSION_RECOVERY_PROMPTS,
recordRemoteSessionRecoveryPrompt,
remoteSessionRecoveryPromptsForThread,
subscribeRemoteSessionRecoveryPrompts,
withRemoteSessionRecoveryPrompt,
} from "./remoteSessionRecoveryPrompts.ts";

const channelId = "f9ee507e-d03c-4570-baa6-02951c38e1cd";

function prompt(overrides = {}) {
return {
agentPubkey: "a".repeat(64),
agentName: "Agent",
session: { channelId, threadRoot: "b".repeat(64) },
expectedRelayUrl: "wss://relay.example",
expectedSignerPubkey: "c".repeat(64),
error: "Sandbox session ended; explicit recovery is required",
...overrides,
};
}

test("a newer failure for the same tenant, agent, and thread replaces the older one", () => {
const first = prompt();
const second = prompt({
agentPubkey: "A".repeat(64),
error: "Sandbox session is ending; recover it explicitly once it has ended",
});
const next = withRemoteSessionRecoveryPrompt(
withRemoteSessionRecoveryPrompt([], first),
second,
);
assert.deepEqual(next, [second]);

const otherTenant = prompt({ expectedSignerPubkey: "d".repeat(64) });
assert.equal(withRemoteSessionRecoveryPrompt(next, otherTenant).length, 2);
});

test("the prompt store stays bounded, keeping the newest failures", () => {
let prompts = [];
for (
let index = 0;
index <= MAX_REMOTE_SESSION_RECOVERY_PROMPTS;
index += 1
) {
prompts = withRemoteSessionRecoveryPrompt(
prompts,
prompt({
session: {
channelId,
threadRoot: index.toString(16).padStart(64, "0"),
},
}),
);
}
assert.equal(prompts.length, MAX_REMOTE_SESSION_RECOVERY_PROMPTS);
assert.equal(prompts[0].session.threadRoot, "1".padStart(64, "0"));
});

test("a thread selects only its own prompts", () => {
const here = prompt();
const otherThread = prompt({
session: { channelId, threadRoot: "e".repeat(64) },
});
const otherChannel = prompt({
session: {
channelId: "00000000-0000-4000-8000-000000000000",
threadRoot: "b".repeat(64),
},
});
assert.deepEqual(
remoteSessionRecoveryPromptsForThread(
[here, otherThread, otherChannel],
here.session,
),
[here],
);
});

test("recording and clearing notify subscribers; clearing matches the start's scope", () => {
let notifications = 0;
const unsubscribe = subscribeRemoteSessionRecoveryPrompts(() => {
notifications += 1;
});
const failed = prompt();
recordRemoteSessionRecoveryPrompt(failed);
assert.deepEqual(getRemoteSessionRecoveryPrompts(), [failed]);
// A later successful start carries no error or name, only its scope.
clearRemoteSessionRecoveryPrompt({
agentPubkey: failed.agentPubkey.toUpperCase(),
session: failed.session,
expectedRelayUrl: ` ${failed.expectedRelayUrl} `,
expectedSignerPubkey: failed.expectedSignerPubkey,
});
assert.deepEqual(getRemoteSessionRecoveryPrompts(), []);
clearRemoteSessionRecoveryPrompt(failed);
assert.equal(notifications, 2, "clearing an absent prompt is silent");
unsubscribe();
});
103 changes: 103 additions & 0 deletions desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession";
import { normalizePubkey } from "@/shared/lib/pubkey";

/**
* A thread-session start that failed with a lifecycle error. The thread panel
* offers the explicit recovery actions for it. Only the failure is kept: the
* user's recovery choice is sent with one command and never stored.
*/
export type RemoteSessionRecoveryPrompt = {
agentPubkey: string;
agentName: string;
session: RemoteAgentSession;
/** Tenant scope of the failed start; the recovery command asserts it. */
expectedRelayUrl: string;
expectedSignerPubkey: string;
error: string;
code?: string;
};

/** Bounds the store: one entry per failed (tenant, agent, thread). */
export const MAX_REMOTE_SESSION_RECOVERY_PROMPTS = 32;

let prompts: readonly RemoteSessionRecoveryPrompt[] = [];
const listeners = new Set<() => void>();

/** Identity of a prompt: the tenant, the agent, and the thread. */
export function remoteSessionRecoveryPromptKey(
prompt: Pick<
RemoteSessionRecoveryPrompt,
"agentPubkey" | "session" | "expectedRelayUrl" | "expectedSignerPubkey"
>,
): string {
return [
prompt.expectedRelayUrl.trim(),
normalizePubkey(prompt.expectedSignerPubkey),
normalizePubkey(prompt.agentPubkey),
prompt.session.channelId,
prompt.session.threadRoot,
].join("\u0000");
}

/** Pure update: replace any prompt with the same key, newest last, bounded. */
export function withRemoteSessionRecoveryPrompt(
current: readonly RemoteSessionRecoveryPrompt[],
prompt: RemoteSessionRecoveryPrompt,
): readonly RemoteSessionRecoveryPrompt[] {
const key = remoteSessionRecoveryPromptKey(prompt);
return [
...current.filter(
(existing) => remoteSessionRecoveryPromptKey(existing) !== key,
),
prompt,
].slice(-MAX_REMOTE_SESSION_RECOVERY_PROMPTS);
}

/** Pure selection: the prompts for one thread. */
export function remoteSessionRecoveryPromptsForThread(
current: readonly RemoteSessionRecoveryPrompt[],
session: RemoteAgentSession,
): RemoteSessionRecoveryPrompt[] {
return current.filter(
(prompt) =>
prompt.session.channelId === session.channelId &&
prompt.session.threadRoot === session.threadRoot,
);
}

function setPrompts(next: readonly RemoteSessionRecoveryPrompt[]): void {
if (next === prompts) return;
prompts = next;
for (const listener of listeners) listener();
}

/** Records a failed thread-session start so its thread can offer recovery. */
export function recordRemoteSessionRecoveryPrompt(
prompt: RemoteSessionRecoveryPrompt,
): void {
setPrompts(withRemoteSessionRecoveryPrompt(prompts, prompt));
}

/** Drops a prompt after its session started, recovered, or was dismissed. */
export function clearRemoteSessionRecoveryPrompt(
prompt: Parameters<typeof remoteSessionRecoveryPromptKey>[0],
): void {
const key = remoteSessionRecoveryPromptKey(prompt);
const next = prompts.filter(
(existing) => remoteSessionRecoveryPromptKey(existing) !== key,
);
if (next.length !== prompts.length) setPrompts(next);
}

export function subscribeRemoteSessionRecoveryPrompts(
listener: () => void,
): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function getRemoteSessionRecoveryPrompts(): readonly RemoteSessionRecoveryPrompt[] {
return prompts;
}
Loading
Loading