Skip to content
Closed
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
170 changes: 170 additions & 0 deletions apps/web/src/hooks/useAgentSessionAutoReconcile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vite-plus/test";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/unstable/reactivity";
import { RpcClientError } from "effect/unstable/rpc";

import {
classifyImportFailure,
isDefinitiveOutcome,
projectReconcileKey,
selectUnreconciledProjects,
} from "./useAgentSessionAutoReconcile";

function makeProject(
environmentId: string,
projectId: string,
workspaceRoot = `/home/user/${projectId}`,
): EnvironmentProject {
return {
environmentId: environmentId as EnvironmentId,
id: projectId as ProjectId,
title: projectId,
workspaceRoot,
defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as EnvironmentProject;
}

describe("selectUnreconciledProjects", () => {
it("returns all projects when none have been reconciled", () => {
const reconciled = new Set<string>();
const projects = [makeProject("env-1", "proj-a"), makeProject("env-1", "proj-b")];
const result = selectUnreconciledProjects(projects, reconciled);
expect(result).toHaveLength(2);
expect(result[0]!.id).toBe("proj-a");
expect(result[1]!.id).toBe("proj-b");
});

it("does not mutate the reconciled set", () => {
const reconciled = new Set<string>();
const projects = [makeProject("env-1", "proj-a")];
selectUnreconciledProjects(projects, reconciled);
expect(reconciled.size).toBe(0);
});

it("skips projects already in the reconciled set", () => {
const project = makeProject("env-1", "proj-a");
const reconciled = new Set([projectReconcileKey(project)]);
const result = selectUnreconciledProjects([project], reconciled);
expect(result).toHaveLength(0);
});

it("returns only projects not yet reconciled", () => {
const projA = makeProject("env-1", "proj-a");
const projB = makeProject("env-1", "proj-b");
const reconciled = new Set([projectReconcileKey(projA)]);
const result = selectUnreconciledProjects([projA, projB], reconciled);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("proj-b");
});

it("returns empty array when projects list is empty", () => {
const result = selectUnreconciledProjects([], new Set());
expect(result).toHaveLength(0);
});

it("distinguishes projects with the same id across different environments", () => {
const reconciled = new Set<string>();
const projects = [makeProject("env-1", "proj-a"), makeProject("env-2", "proj-a")];
const result = selectUnreconciledProjects(projects, reconciled);
expect(result).toHaveLength(2);
});

it("allows retry when a project is not marked reconciled after failure", () => {
const reconciled = new Set<string>();
const projects = [makeProject("env-1", "proj-a")];

const first = selectUnreconciledProjects(projects, reconciled);
expect(first).toHaveLength(1);

const second = selectUnreconciledProjects(projects, reconciled);
expect(second).toHaveLength(1);

reconciled.add(projectReconcileKey(projects[0]!));
const third = selectUnreconciledProjects(projects, reconciled);
expect(third).toHaveLength(0);
});
});

describe("classifyImportFailure", () => {
it("classifies an interrupted result as interrupted", () => {
const result = AsyncResult.failure(Cause.interrupt());
expect(classifyImportFailure(result)).toBe("interrupted");
});

it("classifies an RpcClientError as unsupported-server", () => {
const rpcError = new RpcClientError.RpcClientError({
reason: new RpcClientError.RpcClientDefect({
message: "unknown method",
cause: new Error("socket closed"),
}),
});
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(rpcError)))).toBe(
"unsupported-server",
);
});

it("classifies AgentSessionImportProjectNotFoundError as expected", () => {
const error = { _tag: "AgentSessionImportProjectNotFoundError" as const, projectId: "proj-1" };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("expected");
});

it("classifies AgentSessionImportProjectChangedError as expected", () => {
const error = { _tag: "AgentSessionImportProjectChangedError" as const, projectId: "proj-1" };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("expected");
});

it("classifies AgentSessionScanError as expected", () => {
const error = { _tag: "AgentSessionScanError" as const, operation: "read-settings" };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("expected");
});

it("classifies EnvironmentRpcUnavailableError as expected", () => {
const error = { _tag: "EnvironmentRpcUnavailableError" as const, environmentId: "env-1" };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("expected");
});

it("classifies EnvironmentAuthorizationError as expected", () => {
const error = { _tag: "EnvironmentAuthorizationError" as const };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("expected");
});

it("classifies a die (defect) as unexpected", () => {
expect(classifyImportFailure(AsyncResult.failure(Cause.die(new Error("boom"))))).toBe(
"unexpected",
);
});

it("classifies an unknown tagged error as unexpected", () => {
const error = { _tag: "SomethingWeird" as const };
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(error)))).toBe("unexpected");
});

it("classifies a plain Error as unexpected", () => {
expect(classifyImportFailure(AsyncResult.failure(Cause.fail(new Error("plain"))))).toBe(
"unexpected",
);
});
});

describe("isDefinitiveOutcome", () => {
it("treats expected failures as definitive (no retry)", () => {
expect(isDefinitiveOutcome("expected")).toBe(true);
});

it("treats unsupported-server as non-definitive (retry after upgrade)", () => {
expect(isDefinitiveOutcome("unsupported-server")).toBe(false);
});

it("treats unexpected failures as non-definitive (retry)", () => {
expect(isDefinitiveOutcome("unexpected")).toBe(false);
});

it("treats interrupted as non-definitive (retry)", () => {
expect(isDefinitiveOutcome("interrupted")).toBe(false);
});
});
190 changes: 190 additions & 0 deletions apps/web/src/hooks/useAgentSessionAutoReconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useEffect, useRef } from "react";

import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import { isRpcClientError } from "@t3tools/client-runtime/rpc";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";

import { agentSessionImport } from "../state/agentSessions";
import { useAllEnvironmentShellsBootstrapped, useProjects } from "../state/entities";
import { useAtomCommand } from "../state/use-atom-command";

/** Stable key for a project in a specific environment. */
export function projectReconcileKey(project: {
readonly environmentId: string;
readonly id: string;
}): string {
return `${project.environmentId}\0${project.id}`;
}

/**
* Return the subset of `projects` not yet in `reconciled`.
* Does **not** mark them reconciled — the caller must do so after a
* definitive outcome so transient failures allow retry.
*/
export function selectUnreconciledProjects(
projects: ReadonlyArray<EnvironmentProject>,
reconciled: ReadonlySet<string>,
): ReadonlyArray<EnvironmentProject> {
const pending: EnvironmentProject[] = [];
for (const project of projects) {
if (reconciled.has(projectReconcileKey(project))) continue;
pending.push(project);
}
return pending;
}

const EXPECTED_FAILURE_TAGS = new Set([
"AgentSessionImportProjectNotFoundError",
"AgentSessionImportProjectChangedError",
"AgentSessionScanError",
"EnvironmentRpcUnavailableError",
"EnvironmentAuthorizationError",
]);

/**
* Classify a failed import result so the hook can log the right diagnostic.
*
* - `"unsupported-server"` — the RPC method does not exist on this server
* (pre-#5362 build, e.g. t3@0.0.38). No retry will help; warn once.
* - `"interrupted"` — the effect was cancelled (unmount / environment switch).
* - `"expected"` — a typed domain error (project not found, workspace mismatch,
* scan error). Normal on misconfigured or empty agent homes.
* - `"unexpected"` — an unrecognised defect. Worth logging for debugging.
*/
export function classifyImportFailure(
result: AtomCommandResult<unknown, unknown>,
): "unsupported-server" | "interrupted" | "expected" | "unexpected" {
if (result._tag === "Success") return "expected";
if (isAtomCommandInterrupted(result)) return "interrupted";

const squashed = squashAtomCommandFailure(result);

if (isRpcClientError(squashed)) return "unsupported-server";

if (
squashed != null &&
typeof squashed === "object" &&
"_tag" in squashed &&
typeof squashed._tag === "string" &&
EXPECTED_FAILURE_TAGS.has(squashed._tag)
) {
return "expected";
}

return "unexpected";
}

/**
* Whether the failure kind is definitive enough to mark the project reconciled
* and not retry. Transient failures (unsupported-server, unexpected, interrupted)
* leave the project eligible for retry after a server upgrade or remount.
*/
export function isDefinitiveOutcome(kind: ReturnType<typeof classifyImportFailure>): boolean {
return kind === "expected";
}

/**
* Automatically imports external agent sessions (Claude Code, Codex) for every
* known project once the environment shells are bootstrapped. Runs once per
* project per mount cycle for successful imports; retries on transient failures.
*
* Reuses the existing `agentSessions.import` RPC, which is idempotent: threads
* whose `import:` id already exists are skipped by the server, and the
* per-source file-identity watermark prevents re-reading unchanged transcripts.
*
* Requires a server build that includes #5362 (agentSessions.scan /
* agentSessions.import RPCs). Older servers (e.g. t3@0.0.38) do not expose
* these methods; the hook detects this and logs a one-time warning.
*/
export function useAgentSessionAutoReconcile(): void {
const projects = useProjects();
const bootstrapped = useAllEnvironmentShellsBootstrapped();
const importSessions = useAtomCommand(agentSessionImport, { reportFailure: false });
const reconciledRef = useRef(new Set<string>());
const unsupportedServersRef = useRef(new Set<string>());

useEffect(() => {
if (!bootstrapped) return;

const pending = selectUnreconciledProjects(projects, reconciledRef.current);
if (pending.length === 0) return;

let cancelled = false;
const run = async () => {
for (const project of pending) {
if (cancelled) return;

const key = projectReconcileKey(project);

if (unsupportedServersRef.current.has(project.environmentId)) continue;

const result = await importSessions({
environmentId: project.environmentId,
input: {
projectId: project.id,
expectedWorkspaceRoot: project.workspaceRoot,
},
});

if (cancelled) return;

if (result._tag === "Success") {
reconciledRef.current.add(key);
console.info(
`[auto-reconcile] project "${project.title}" (${project.id}, root "${project.workspaceRoot}"): ` +
`imported ${result.value.importedCount}, skipped ${result.value.skippedCount}`,
);
continue;
}

const kind = classifyImportFailure(result);

if (kind === "interrupted") continue;

if (kind === "unsupported-server") {
unsupportedServersRef.current.add(project.environmentId);
for (const key of reconciledRef.current) {
if (key.startsWith(`${project.environmentId}\0`)) {
reconciledRef.current.delete(key);
}
}
console.warn(
`[auto-reconcile] Server for environment "${project.environmentId}" does not support agentSessions.import. ` +
`Auto-reconcile requires a server build that includes PR #5362 (shipped after t3@0.0.38). ` +
`Upgrade the server to enable automatic session import.`,
);
continue;
}

if (isDefinitiveOutcome(kind)) {
reconciledRef.current.add(key);
}

const squashed = squashAtomCommandFailure(result);
const errorMessage =
squashed instanceof Error ? squashed.message : JSON.stringify(squashed);

if (kind === "expected") {
console.warn(
`[auto-reconcile] Could not import agent sessions for project "${project.title}" ` +
`(${project.id}, env ${project.environmentId}, root "${project.workspaceRoot}"): ${errorMessage}`,
);
} else {
console.error(
`[auto-reconcile] Unexpected error importing agent sessions for project "${project.title}" ` +
`(${project.id}, env ${project.environmentId}, root "${project.workspaceRoot}"): ${errorMessage}`,
);
}
}
};
void run();

return () => {
cancelled = true;
};
}, [bootstrapped, importSessions, projects]);
}
2 changes: 2 additions & 0 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useEffect, useMemo } from "react";
import { isCommandPaletteOpen } from "../commandPaletteBus";
import { useClientSettings, useLegacySidebarEnabled } from "../hooks/useSettings";
import { openCommandPalette } from "../commandPaletteBus";
import { useAgentSessionAutoReconcile } from "../hooks/useAgentSessionAutoReconcile";
import { useProjects } from "../state/entities";
import { usePrimaryEnvironmentId } from "../state/environments";
import { selectProjectGroupingSettings } from "../logicalProject";
Expand Down Expand Up @@ -175,6 +176,7 @@ function ChatRouteGlobalShortcuts() {
}

function ChatRouteLayout() {
useAgentSessionAutoReconcile();
return (
<>
<ChatRouteGlobalShortcuts />
Expand Down
Loading