Skip to content
Merged
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
227 changes: 224 additions & 3 deletions apps/mobile/src/features/threads/use-composer-command-menu.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from "vite-plus/test";
import { ProviderDriverKind } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
import { act, createElement } from "react";
import { createRoot, type Root } from "react-dom/client";

const refreshProviders = vi.hoisted(() => vi.fn());
vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));

vi.mock("../../state/queries", () => ({
Expand All @@ -8,19 +17,21 @@ vi.mock("../../state/queries", () => ({
}));
vi.mock("../../state/use-composer-drafts", () => ({
getComposerDraftSnapshot: vi.fn(),
readComposerDraftSelection: vi.fn(),
setComposerDraftContext: vi.fn(),
}));
vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
vi.mock("../../state/server", () => ({
serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
}));
vi.mock("../../state/use-atom-command", () => ({
useAtomCommand: () => vi.fn(),
useAtomCommand: () => refreshProviders,
}));

import {
buildComposerSlashCommandItems,
resolveComposerCommandSelection,
useComposerCommandMenu,
} from "./use-composer-command-menu";

describe("mobile slash commands", () => {
Expand Down Expand Up @@ -101,3 +112,213 @@ describe("mobile slash commands", () => {
).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
});
});

describe("workspace command discovery retry", () => {
let root: Root;
const environmentId = EnvironmentId.make("test-environment");
const instanceId = ProviderInstanceId.make("claude");
const provider = {
instanceId,
driver: ProviderDriverKind.make("claude"),
enabled: true,
installed: true,
version: "1.0.0",
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
workspaceSnapshots: [
{
cwd: "/project-a",
checkedAt: "2026-01-01T00:00:00.000Z",
slashCommandsPending: true,
slashCommands: [{ name: "compact" }],
skills: [],
},
],
} satisfies ServerProvider;

function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) {
useComposerCommandMenu({
draftMessage: "/project",
ownerKey: null,
environmentId,
projectCwd: cwd,
selectedProviderStatus: status,
hasThread: false,
hasCompactableConversation: false,
onChangeDraftMessage: () => {},
});
return null;
}

beforeEach(() => {
vi.useFakeTimers();
refreshProviders.mockReset();
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } });
const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
const container = {
nodeType: 1,
tagName: "DIV",
namespaceURI: "http://www.w3.org/1999/xhtml",
ownerDocument: document,
addEventListener() {},
removeEventListener() {},
};
vi.stubGlobal("document", document);
vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
root = createRoot(container as unknown as HTMLElement);
});

afterEach(async () => {
await act(async () => {
root.unmount();
});
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("retries partial commands after the cooldown without editing the draft", async () => {
const recovered = {
...provider,
workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }],
};
refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } });
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a" }));
});
expect(refreshProviders).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(9_999));
expect(refreshProviders).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(1));
expect(refreshProviders).toHaveBeenCalledTimes(2);
expect(refreshProviders).toHaveBeenLastCalledWith({
environmentId,
input: { instanceId, cwd: "/project-a" },
});
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(refreshProviders).toHaveBeenCalledTimes(2);
});

it.each(["pi", "acpRegistry"])(
"does not poll a healthy %s workspace without discovery",
async (driver) => {
const unsupported = {
...provider,
driver: ProviderDriverKind.make(driver),
workspaceSnapshots: [],
};
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } });
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a", status: unsupported }));
});
expect(refreshProviders).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(30_000));
expect(refreshProviders).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
},
);

it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => {
const missing = { ...provider, workspaceSnapshots: [] };
const recovered = {
...provider,
workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
...snapshot,
slashCommandsPending: false,
})),
};
refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } });
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
});
await act(() => vi.advanceTimersByTimeAsync(5_000));
expect(refreshProviders).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a", status: provider }));
});
await act(() => vi.advanceTimersByTimeAsync(4_999));
expect(refreshProviders).toHaveBeenCalledTimes(1);
await act(() => vi.advanceTimersByTimeAsync(1));
expect(refreshProviders).toHaveBeenCalledTimes(2);
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(refreshProviders).toHaveBeenCalledTimes(2);
});

it("does not retry when a concurrent scan publishes a complete workspace", async () => {
const missing = { ...provider, workspaceSnapshots: [] };
const recovered = {
...provider,
workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
...snapshot,
slashCommandsPending: false,
})),
};
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } });
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
});
await act(() => vi.advanceTimersByTimeAsync(5_000));
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a", status: recovered }));
});
await act(() => vi.advanceTimersByTimeAsync(30_000));
expect(refreshProviders).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

it("retries a rejected refresh while published discovery remains pending", async () => {
const recovered = {
...provider,
workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
...snapshot,
slashCommandsPending: false,
})),
};
refreshProviders.mockRejectedValueOnce(new Error("Connection lost"));
refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a" }));
});
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(refreshProviders).toHaveBeenCalledTimes(2);
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(refreshProviders).toHaveBeenCalledTimes(2);
});

it("cancels the old workspace retry and does not duplicate an in-flight request", async () => {
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a" }));
});
refreshProviders.mockImplementationOnce(() => new Promise(() => {}));
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-b" }));
});
expect(refreshProviders).toHaveBeenCalledTimes(2);
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(refreshProviders).toHaveBeenCalledTimes(2);
expect(refreshProviders).toHaveBeenLastCalledWith({
environmentId,
input: { instanceId, cwd: "/project-b" },
});
expect(vi.getTimerCount()).toBe(0);
});

it("cancels a scheduled retry when the composer unmounts", async () => {
await act(async () => {
root.render(createElement(Probe, { cwd: "/project-a" }));
});
await act(async () => {
root.unmount();
});
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(refreshProviders).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
});
55 changes: 43 additions & 12 deletions apps/mobile/src/features/threads/use-composer-command-menu.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
getProviderSkillsForSlashMenu,
getProviderSlashCommandsForSlashMenu,
isProviderSkillUserInvocable,
hasCompleteProviderWorkspaceSnapshot,
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
} from "@t3tools/client-runtime/providerSkills";
Expand Down Expand Up @@ -245,17 +246,43 @@ export function useComposerCommandMenu({
reportFailure: false,
});
const selectedProviderInstanceId = selectedProviderStatus?.instanceId;
const hasWorkspaceSnapshot = Boolean(
projectCwd &&
selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd),
const hasWorkspaceSnapshot = hasCompleteProviderWorkspaceSnapshot(
selectedProviderStatus,
projectCwd,
);
const workspaceRefreshKeyRef = useRef<string | null>(null);
const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null);
const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{
key: string;
notBefore: number;
} | null>(null);
const workspaceRefreshScopeKey =
environmentId && projectCwd && selectedProviderInstanceId
? `${environmentId}:${selectedProviderInstanceId}:${projectCwd}`
: null;
const workspaceSlashCommandsPending =
selectedProviderStatus?.workspaceSnapshots?.some(
(snapshot) => snapshot.cwd === projectCwd && snapshot.slashCommandsPending === true,
) ?? false;
useEffect(() => {
if (
!workspaceSlashCommandsPending ||
!workspaceRefreshRetry ||
workspaceRefreshRetry.key !== workspaceRefreshScopeKey
)
return;
const timeout = setTimeout(
() => {
setWorkspaceRefreshRetry((current) => (current === workspaceRefreshRetry ? null : current));
},
Math.max(0, workspaceRefreshRetry.notBefore - Date.now()),
);
return () => clearTimeout(timeout);
}, [workspaceRefreshRetry, workspaceRefreshScopeKey, workspaceSlashCommandsPending]);
const hadWorkspaceSnapshotRef = useRef(false);
useEffect(() => {
if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
workspaceRefreshKeyRef.current = null;
workspaceRefreshRetryRef.current = null;
setWorkspaceRefreshRetry(null);
}
hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
}, [hasWorkspaceSnapshot]);
Expand All @@ -265,29 +292,32 @@ export function useComposerCommandMenu({
if (workspaceRefreshKeyRef.current === key) return;
if (hasWorkspaceSnapshot) {
workspaceRefreshKeyRef.current = key;
workspaceRefreshRetryRef.current = null;
setWorkspaceRefreshRetry(null);
return;
}
const retry = workspaceRefreshRetryRef.current;
const retry = workspaceRefreshRetry;
if (retry?.key === key && Date.now() < retry.notBefore) return;
workspaceRefreshKeyRef.current = key;
const retryLater = () => {
if (workspaceRefreshKeyRef.current !== key) return;
workspaceRefreshKeyRef.current = null;
workspaceRefreshRetryRef.current = {
setWorkspaceRefreshRetry({
key,
notBefore: Date.now() + WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS,
};
});
};
void refreshProviders({
environmentId,
input: { instanceId: selectedProviderInstanceId, cwd: projectCwd },
}).then((result) => {
const refreshed =
result._tag === "Success" &&
result.value.providers
.find((provider) => provider.instanceId === selectedProviderInstanceId)
?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd);
hasCompleteProviderWorkspaceSnapshot(
result.value.providers.find(
(provider) => provider.instanceId === selectedProviderInstanceId,
),
projectCwd,
);
if (!refreshed && workspaceRefreshKeyRef.current === key) {
retryLater();
}
Expand All @@ -299,6 +329,7 @@ export function useComposerCommandMenu({
projectCwd,
refreshProviders,
selectedProviderInstanceId,
workspaceRefreshRetry,
]);

const trigger = useMemo(() => {
Expand Down
19 changes: 8 additions & 11 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
checkClaudeProviderStatus,
makePendingClaudeProvider,
probeClaudeCapabilities,
probeClaudeWorkspaceSnapshot,
} from "../Layers/ClaudeProvider.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import * as ModelManifest from "../ModelManifest.ts";
Expand Down Expand Up @@ -69,7 +70,6 @@ import {
makeClaudeContinuationGroupKey,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";
import { discoverClaudeSkills } from "./ClaudeSkills.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

const DRIVER_KIND = ProviderDriverKind.make("claudeAgent");
Expand Down Expand Up @@ -348,16 +348,13 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
snapshot,
invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
snapshotForCwd: (cwd: string) =>
!effectiveConfig.enabled
? snapshot.getSnapshot
: Effect.all([
snapshot.getSnapshot,
discoverClaudeSkills(effectiveConfig, cwd, processEnv),
]).pipe(
Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
snapshot.getSnapshot.pipe(
Effect.flatMap((machineSnapshot) =>
probeClaudeWorkspaceSnapshot(effectiveConfig, machineSnapshot, cwd, processEnv),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
orchestrationAdapter,
textGeneration,
consumeResetCredit,
Expand Down
Loading
Loading