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
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { RegistryContext } from "@effect/atom-react";
import type { PullRequestChecks, PullRequestDetail } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

const fixture = vi.hoisted(() => ({
active: "active",
focused: true,
reads: 0,
appListeners: new Set<() => void>(),
focusListeners: new Set<() => void>(),
query: null as Atom.Atom<AsyncResult.AsyncResult<PullRequestChecks, never>> | null,
}));

vi.mock("react-native", () => ({
AppState: {
get currentState() {
return fixture.active;
},
addEventListener: (_event: string, listener: () => void) => {
fixture.appListeners.add(listener);
return { remove: () => fixture.appListeners.delete(listener) };
},
},
}));
vi.mock("@react-navigation/native", async () => {
const { useEffect, useSyncExternalStore } = await import("react");
const subscribe = (listener: () => void) => {
fixture.focusListeners.add(listener);
return () => fixture.focusListeners.delete(listener);
};
const useIsFocused = () => useSyncExternalStore(subscribe, () => fixture.focused);
return {
useIsFocused,
useFocusEffect: (effect: () => (() => void) | undefined) => {
const focused = useIsFocused();
useEffect(() => (focused ? effect() : undefined), [effect, focused]);
},
};
});
vi.mock("../../state/entities", () => ({
useEnvironmentServerConfig: () => ({
environment: { capabilities: { pullRequestChecks: true } },
}),
}));
vi.mock("../../state/pull-requests", () => ({
composerPullRequests: { checks: () => fixture.query },
}));
vi.mock("./usePullRequestConnected", () => ({ usePullRequestConnected: () => true }));
vi.mock("./usePullRequestDiff", () => ({ pullRequestTarget: (params: unknown) => params }));

import { usePullRequestLiveChecks } from "./usePullRequestLiveChecks";

function setup(active = "active") {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers({
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
});
vi.setSystemTime(0);
fixture.active = active;
fixture.focused = true;
fixture.reads = 0;
fixture.appListeners.clear();
fixture.focusListeners.clear();
fixture.query = Atom.make(
Effect.sync(() => {
fixture.reads++;
return { state: "open" as const, checks: [], headSha: "head", observedAt: fixture.reads };
}),
).pipe(Atom.setIdleTTL(0));
const registry = AtomRegistry.make();
const root = createRoot(document.createElement("div"));
const detail = { state: "open", checks: [], observedAt: 0 } as unknown as PullRequestDetail;
function Probe() {
usePullRequestLiveChecks(
{ environmentId: "local", projectId: "project", repository: "acme/app", number: 7 },
detail,
);
return null;
}
act(() =>
root.render(
<RegistryContext.Provider value={registry}>
<Probe />
</RegistryContext.Provider>,
),
);
return {
active(state: string) {
act(() => {
fixture.active = state;
for (const listener of fixture.appListeners) listener();
});
},
focused(focused: boolean) {
act(() => {
fixture.focused = focused;
for (const listener of fixture.focusListeners) listener();
});
},
async advance(milliseconds: number) {
await act(async () => vi.advanceTimersByTimeAsync(milliseconds));
},
[Symbol.dispose]() {
act(() => root.unmount());
registry.dispose();
vi.useRealTimers();
},
};
}

it("polls only the foreground focused route and resumes one checks subscription", async () => {
using screen = setup();
expect(fixture.reads).toBe(1);
await screen.advance(45_000);
expect(fixture.reads).toBe(2);
screen.active("background");
await screen.advance(180_000);
expect(fixture.reads).toBe(2);
screen.active("active");
expect(fixture.reads).toBe(3);
await screen.advance(45_000);
expect(fixture.reads).toBe(4);
screen.focused(false);
await screen.advance(180_000);
expect(fixture.reads).toBe(4);
screen.focused(true);
expect(fixture.reads).toBe(5);
});

it("does not subscribe to checks when the route first mounts in the background", async () => {
using screen = setup("background");
await screen.advance(180_000);
expect(fixture.reads).toBe(0);
screen.active("active");
expect(fixture.reads).toBe(1);
});
23 changes: 18 additions & 5 deletions apps/mobile/src/features/pull-requests/usePullRequestLiveChecks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useFocusEffect } from "@react-navigation/native";
import { useIsFocused } from "@react-navigation/native";
import { useEffect } from "react";
import {
pullRequestChecksRefreshIntervalMs,
resolvePullRequestDetailChecks,
} from "@t3tools/client-runtime/pull-request-checks";
import type { PullRequestDetail } from "@t3tools/contracts";
import { useAppActive } from "../../lib/appState";
import { useEnvironmentServerConfig } from "../../state/entities";
import { composerPullRequests } from "../../state/pull-requests";
import { useEnvironmentQuery } from "../../state/query";
Expand All @@ -19,24 +21,35 @@ export function usePullRequestLiveChecks(
detail: PullRequestDetail | null,
): PullRequestDetail | null {
const target = pullRequestTarget(params);
const active = useAppActive();
const focused = useIsFocused();
const connected = usePullRequestConnected(target.environmentId);
const supported =
useEnvironmentServerConfig(target.environmentId)?.environment.capabilities.pullRequestChecks ===
true;
const checks = useEnvironmentQuery(
supported && detail !== null ? composerPullRequests.checks(target) : null,
active && focused && connected && supported && detail !== null
? composerPullRequests.checks(target)
: null,
);
// Both reads carry the server's observedAt; without it the poll is the fresher stream.
const live = resolvePullRequestDetailChecks(detail, checks.data, true);
// A failed read keeps polling; only a host that answers "no checks" (null) stops it.
const enabled =
connected && supported && live?.state === "open" && !(checks.isSuccess && checks.data === null);
active &&
focused &&
connected &&
supported &&
!checks.isPending &&
live?.state === "open" &&
!(checks.isSuccess && checks.data === null);
const intervalMs = pullRequestChecksRefreshIntervalMs(live?.checks ?? []);
const { refresh } = checks;
useFocusEffect(() => {
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- polls while the route is focused and the native app is active
useEffect(() => {
if (!enabled) return;
const timer = setInterval(refresh, intervalMs);
return () => clearInterval(timer);
});
}, [enabled, intervalMs, refresh]);
return live;
}
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const reactTests = [
"src/features/files/FileCodeRelationships.test.tsx",
"src/features/loom/ProjectLoomRouteScreen.test.tsx",
"src/features/pull-requests/usePullRequestList.test.tsx",
"src/features/pull-requests/usePullRequestLiveChecks.test.tsx",
"src/features/files/ZipArchiveView.test.tsx",
"src/features/pull-requests/guide/useReviewGuide.test.tsx",
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ if (
// Guard sockets before either dispatch path can load outbound-capable modules.
const { ignoreMacosTypeOfServiceEinval } = await import("./outboundHttp.ts");
ignoreMacosTypeOfServiceEinval();
if (command === "__document-text") {
if (command === "__github-cli") {
const { runAgentGitHubCli } = await import("./sourceControl/agentGitHubCli.ts");
await runAgentGitHubCli(process.argv[3]!, process.argv.slice(4));
} else if (command === "__document-text") {
const { extractDocumentText } = await import("./provider/documentTextWorker.ts");
try {
process.stdout.write(await extractDocumentText(process.argv[3]!, process.argv[4]!));
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ const PullRequestStatusTool = Tool.make("pull_request_status", {

const PullRequestFailuresTool = Tool.make("pull_request_failures", {
description:
"Why a PR's CI failed: each failing check's failed steps, annotations, and a bounded log excerpt around the errors. Use instead of gh run view --log-failed or gh api .../jobs/<id>/logs.",
"Why one PR's CI failed: each failing check's failed steps, annotations, and a bounded log excerpt around the errors. Accepts one PR per call; make separate calls for several PRs. Use instead of gh run view --log-failed or gh api .../jobs/<id>/logs.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
...PullRequestFailuresInput.fields,
Expand All @@ -440,7 +440,7 @@ const PullRequestFailuresTool = Tool.make("pull_request_failures", {

const PullRequestFeedbackTool = Tool.make("pull_request_feedback", {
description:
"Unresolved review threads, review verdicts, and comments on a PR, bot findings included, newest first. Use instead of gh api .../reviews, .../comments, or reviewThreads GraphQL.",
"Unresolved review threads, review verdicts, and comments on one PR, bot findings included, newest first. Accepts one PR per call; make separate calls for several PRs. Use instead of gh api .../reviews, .../comments, or reviewThreads GraphQL.",
parameters: Schema.Struct({
environmentId: OrchestratorMcpEnvironmentTarget,
...PullRequestReadInput.fields,
Expand Down
65 changes: 60 additions & 5 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1142,12 +1142,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
onRequest: (method: string, params: unknown) => Effect.Effect<void> = () => Effect.void,
readChildMetadata?: Parameters<typeof withCodexReplayChildMetadata>[2],
runtimeOptions: Partial<
Pick<CodexAdapterV2.CodexAdapterV2Options, "settings" | "resolveRuntime">
> & { readonly modelProvider?: string | null } = {},
Pick<CodexAdapterV2.CodexAdapterV2Options, "settings" | "resolveRuntime" | "environment">
> & { readonly modelProvider?: string | null; readonly shellPath?: string } = {},
beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"],
) =>
Effect.gen(function* () {
const { modelProvider = null, ...adapterRuntimeOptions } = runtimeOptions;
const { modelProvider = null, shellPath, ...adapterRuntimeOptions } = runtimeOptions;
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const serverConfig = yield* ProviderReplayHarness.makeReplayServerConfig(
Expand Down Expand Up @@ -1188,7 +1188,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
? Effect.sync(() => {
configReadRequests.push(params);
return {
config: { model_provider: modelProvider },
config: {
model_provider: modelProvider,
...(shellPath === undefined
? {}
: { shell_environment_policy: { set: { PATH: shellPath } } }),
},
origins: {},
};
})
Expand All @@ -1208,8 +1213,8 @@ describe("CodexAdapterV2 post-settle continuation", () => {
const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({
instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CODEX_SETTINGS,
...adapterRuntimeOptions,
environment: {},
...adapterRuntimeOptions,
clientFactory,
crypto: yield* Crypto.Crypto,
fileSystem,
Expand Down Expand Up @@ -1591,6 +1596,56 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);

it.effect.each([undefined, "/explicit/codex/bin"])(
"retains the hydrated managed gh PATH while respecting explicit Codex PATH %s",
(shellPath) =>
Effect.gen(function* () {
const managedPath =
"/t3/github/bin/launcher:/user/.nvm/bin:/user/.asdf/shims:/user/bin:/bin";
const expectedConfig = {
...CodexAdapterV2.CODEX_THREAD_CONFIG,
...(shellPath === undefined ? { "shell_environment_policy.set.PATH": managedPath } : {}),
};
const entries = codexReplayPreamble({
nativeThreadId: "managed-gh-thread",
nativeTurnId: "unused",
prompt: "unused",
}).slice(0, 5);
entries[3] = {
type: "expect_outbound",
label: "thread/start",
frame: { id: 2, method: "thread/start", params: { config: expectedConfig } },
};
const requests: Array<unknown> = [];
const harness = yield* makeCodexReplayHarness(
makeCodexReplayTranscript({ scenario: "managed-gh-path", entries }),
() => Effect.void,
(method, params) =>
Effect.sync(() => {
if (method === "thread/start") requests.push(params);
}),
undefined,
{
...(shellPath === undefined ? {} : { shellPath }),
environment: {
PATH: managedPath,
T3_GH_SHIM_DIR: "/t3/github/bin/launcher",
CUSTOM: "preserved",
},
},
);
assert.deepEqual(requests, [
{ cwd: "/workspace", model: "gpt-5.4", config: expectedConfig },
]);
assert.equal(harness.configReadRequests.length, 1);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("adds the session MCP server without replacing launch-argument servers", () =>
Effect.gen(function* () {
const scenario = "launch-mcp-servers";
Expand Down
Loading
Loading