Merge remote-tracking branch 'origin/main' into paseo-cloud-worktree-plan

This commit is contained in:
Mohamed Boudra
2026-07-13 17:05:44 +02:00
42 changed files with 1093 additions and 315 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+14 -63
View File
@@ -33,7 +33,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Check formatting
run: npx oxfmt --check .
@@ -51,7 +51,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Lint lockfile
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
@@ -75,7 +75,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
@@ -111,9 +111,9 @@ jobs:
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
@@ -139,35 +139,8 @@ jobs:
node-version: "22"
cache: "npm"
- name: Install dependencies with Electron retry
if: runner.os != 'Windows'
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
- name: Install dependencies with Electron retry
if: runner.os == 'Windows'
shell: pwsh
run: |
for ($attempt = 1; $attempt -le 3; $attempt++) {
npm ci
if ($LASTEXITCODE -eq 0) {
exit 0
}
if ($attempt -eq 3) {
exit $LASTEXITCODE
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Build server stack
run: npm run build:server
@@ -187,18 +160,7 @@ jobs:
cache: "npm"
- name: Install dependencies with retry
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -222,7 +184,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:client
@@ -258,18 +220,7 @@ jobs:
cache: "npm"
- name: Install dependencies with retry
run: |
for attempt in 1 2 3; do
if npm ci; then
exit 0
else
exit_code=$?
fi
if [ "$attempt" -eq 3 ]; then
exit $exit_code
fi
sleep $((attempt * 20))
done
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -282,7 +233,7 @@ jobs:
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
@@ -317,7 +268,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Build relay
run: npm run build:relay
@@ -343,10 +294,10 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
run: npm run test --workspace=@getpaseo/cli
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
scope: "@boudra"
- name: Install dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/relay --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/relay
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
cache: "npm"
- name: Install dependencies
run: npm ci --workspace=@getpaseo/website --include-workspace-root
run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root
- name: Typecheck
run: npm run typecheck --workspace=@getpaseo/website
+4 -4
View File
@@ -127,7 +127,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -227,7 +227,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -325,7 +325,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -425,7 +425,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
scope: "@boudra"
- name: Install JS dependencies
run: npm ci
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9 -3
View File
@@ -48,8 +48,13 @@ dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, express only the destination with `navigateToWorkspace()`.
Do not make the caller branch on its current route.
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
When the action names a specific tab, pass it as `target`; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
@@ -133,7 +138,8 @@ Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
- [ ] Did a route return to a workspace? Use `navigateToWorkspace()` and pass a
`target` when the action names a specific tab.
- [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership
+19 -4
View File
@@ -54,14 +54,14 @@ async function expectChatHistoryPill(page: Page): Promise<void> {
test.describe("Assistant fork menu", () => {
test.describe.configure({ timeout: 180_000 });
test("forks an assistant turn into a new workspace draft tab", async ({
test("focuses a forked assistant turn in a new workspace draft tab", async ({
page,
seedForkWorkspace,
}) => {
const session = await seedForkWorkspace({
repoPrefix: "assistant-fork-tab-",
title: "Assistant fork tab",
initialPrompt: "emit 1 coalesced agent stream updates for assistant fork tab.",
repoPrefix: "assistant-fork-focused-tab-",
title: "Assistant fork focused tab",
initialPrompt: "emit 1 coalesced agent stream updates for initial assistant fork turn.",
model: "ten-second-stream",
});
@@ -70,9 +70,24 @@ test.describe("Assistant fork menu", () => {
await awaitAssistantMessage(page);
await session.client.waitForFinish(session.agentId, 45_000);
await submitMessage(page, "emit 1 coalesced agent stream updates while this tab is visible.");
await session.client.waitForFinish(session.agentId, 45_000);
await awaitAssistantMessage(page);
const agentTab = page.getByTestId(`workspace-tab-agent_${session.agentId}`);
await expect(agentTab).toHaveAttribute("aria-selected", "true");
await openAssistantForkMenu(page);
await page.getByTestId("assistant-fork-menu-new-tab").click();
const selectedTab = page
.getByTestId("workspace-tabs-row")
.getByRole("button")
.and(page.locator('[aria-selected="true"]'));
await expect(selectedTab).toHaveAttribute("data-testid", /^workspace-tab-draft_/, {
timeout: 30_000,
});
await expect(agentTab).toHaveAttribute("aria-selected", "false");
await expectChatHistoryPill(page);
});
+15
View File
@@ -181,6 +181,21 @@ export async function expectNewWorkspaceProjectSelected(
await expect(projectPicker).toContainText(projectDisplayName);
}
export async function fillNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeVisible({ timeout: 30_000 });
await composer.fill(draft);
}
export async function expectNewWorkspaceDraft(page: Page, draft: string): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue(draft);
}
export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise<void> {
await page.getByTestId("host-picker-trigger").click();
await page.getByText(hostLabel, { exact: true }).click();
}
export async function submitNewWorkspacePrompt(
page: Page,
prompt = "Hello from e2e",
@@ -0,0 +1,88 @@
import { test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import {
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
selectNewWorkspaceHost,
selectNewWorkspaceProject,
} from "./helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
const DRAFT = `Please investigate the workspace startup failure.
Trace the request from the app through the daemon, preserve the existing behavior, and explain the root cause before making changes.`;
test.describe("New workspace composer draft", () => {
test.describe.configure({ timeout: 240_000 });
test("keeps the draft when the project changes", async ({ page }) => {
const firstProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-a-",
});
const secondProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-project-b-",
});
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: firstProject.projectId,
projectDisplayName: firstProject.projectDisplayName,
});
await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceProject(page, {
projectKey: secondProject.projectId,
projectDisplayName: secondProject.projectDisplayName,
});
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await secondProject.cleanup();
await firstProject.cleanup();
}
});
test("keeps the draft when the host changes", async ({ page }) => {
const project: SeededWorkspace = await seedWorkspace({
repoPrefix: "new-workspace-draft-host-",
});
const secondaryServerId = "new-workspace-draft-secondary-host";
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: "Primary host",
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: secondaryServerId,
label: "Secondary host",
endpoint: "127.0.0.1:9",
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, DRAFT);
await selectNewWorkspaceHost(page, "Secondary host");
await expectNewWorkspaceDraft(page, DRAFT);
} finally {
await project.cleanup();
}
});
});
+3 -3
View File
@@ -81,7 +81,7 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
@@ -423,7 +423,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
if (context.workspaceId) {
navigateToPreparedWorkspaceTab({
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
@@ -491,7 +491,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
throw new Error(t("message.actions.forkMissingWorkspace"));
}
const draftId = await prepareForkDraft();
navigateToPreparedWorkspaceTab({
navigateToWorkspace({
serverId: resolvedServerId,
workspaceId,
target: buildForkDraftTabTarget(draftSetup, draftId),
+7 -1
View File
@@ -83,11 +83,17 @@ export interface ChatHistoryContextAttachment {
};
}
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
| {
kind: "github_pr";
item: GitHubSearchItem;
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
};
export type WorkspaceComposerAttachment =
| {
@@ -1677,7 +1677,7 @@ function WorkspaceRowItem({
return;
}
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return (
@@ -345,7 +345,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => {
if (!workspace.serverId) return;
onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId });
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return (
@@ -26,7 +26,7 @@ import type {
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
@@ -218,7 +218,7 @@ export function WorkspaceSetupDialog() {
return;
}
navigateToPreparedWorkspaceTab({
navigateToWorkspace({
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
target,
@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace(action.serverId, action.workspaceId);
navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId });
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
return {
@@ -19,8 +23,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
function prAttachment(
item: GitHubSearchItem,
owner?: "new-workspace-picker",
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return { kind: "github_pr", item };
return { kind: "github_pr", item, ...(owner ? { owner } : {}) };
}
function issueAttachment(number: number): UserComposerAttachment {
@@ -43,57 +48,88 @@ describe("syncPickerPrAttachment", () => {
const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({
attachments: [],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr },
});
expect(result.attachedPrNumber).toBe(202);
expect(result.attachments).toEqual([prAttachment(pr)]);
expect(result).toEqual([prAttachment(pr, "new-workspace-picker")]);
});
it("selects a branch without modifying attachments when no previous picker PR", () => {
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue],
previousPickerPrNumber: null,
item: { kind: "branch", name: "dev" },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toEqual([issue]);
expect(result).toEqual([issue]);
});
it("replaces the previous picker PR when a different PR is selected", () => {
const prA = makePrItem(202, "Refactor picker", "feature/picker");
const prB = makePrItem(303, "Polish chip", "feature/chip");
const result = syncPickerPrAttachment({
attachments: [prAttachment(prA)],
previousPickerPrNumber: 202,
attachments: [prAttachment(prA, "new-workspace-picker")],
item: { kind: "github-pr", item: prB },
});
expect(result.attachedPrNumber).toBe(303);
expect(result.attachments).toEqual([prAttachment(prB)]);
expect(result).toEqual([prAttachment(prB, "new-workspace-picker")]);
});
it("removes the previous picker PR and adds no new attachment when a branch is selected", () => {
const pr = makePrItem(202, "Refactor picker");
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue, prAttachment(pr)],
previousPickerPrNumber: 202,
attachments: [issue, prAttachment(pr, "new-workspace-picker")],
item: { kind: "branch", name: "dev" },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toEqual([issue]);
expect(result).toEqual([issue]);
});
it("does not duplicate a PR that was already manually attached by the user", () => {
const pr = makePrItem(202, "Refactor picker");
const result = syncPickerPrAttachment({
attachments: [prAttachment(pr)],
previousPickerPrNumber: null,
item: { kind: "github-pr", item: pr },
});
expect(result.attachedPrNumber).toBeNull();
expect(result.attachments).toHaveLength(1);
expect(result).toEqual([prAttachment(pr)]);
});
it("clears a persisted picker selection without removing user-added attachments", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manuallyAttachedPr = prAttachment(makePrItem(303, "Manual PR"));
const issue = issueAttachment(44);
const result = syncPickerPrAttachment({
attachments: [issue, pickerPr, manuallyAttachedPr],
item: null,
});
expect(result).toEqual([issue, manuallyAttachedPr]);
});
});
describe("clearPickerPrAttachmentForTargetChange", () => {
it("keeps the picker selection when the target is reselected", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const attachments = [pickerPr];
expect(
clearPickerPrAttachmentForTargetChange({
attachments,
currentTargetId: "server-a",
nextTargetId: "server-a",
}),
).toBe(attachments);
});
it("clears only the picker-owned PR when the target changes", () => {
const pickerPr = prAttachment(makePrItem(202, "Picker PR"), "new-workspace-picker");
const manualPr = prAttachment(makePrItem(303, "Manual PR"));
expect(
clearPickerPrAttachmentForTargetChange({
attachments: [pickerPr, manualPr],
currentTargetId: "server-a",
nextTargetId: "server-b",
}),
).toEqual([manualPr]);
});
});
@@ -1,37 +1,60 @@
import type { UserComposerAttachment } from "@/attachments/types";
import {
NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
type UserComposerAttachment,
} from "@/attachments/types";
import type { PickerItem } from "./new-workspace-picker-item";
// The picker "owns" at most one PR attachment at a time. When the user selects
// a different item the previously-owned PR is removed before the new one is added.
// User-added attachments for other PRs/issues are left untouched.
function isPickerOwnedPrAttachment(attachment: UserComposerAttachment): attachment is Extract<
UserComposerAttachment,
{ kind: "github_pr" }
> & {
owner: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
} {
return (
attachment.kind === "github_pr" && attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
);
}
// Ownership lives on the attachment because drafts outlive this component.
// The picker owns at most one PR; user-added PRs and issues remain untouched.
export function syncPickerPrAttachment(input: {
attachments: UserComposerAttachment[];
previousPickerPrNumber: number | null;
item: PickerItem;
}): { attachments: UserComposerAttachment[]; attachedPrNumber: number | null } {
let nextAttachments = input.attachments;
let attachedPrNumber: number | null = null;
item: PickerItem | null;
}): UserComposerAttachment[] {
const nextAttachments = input.attachments.filter(
(attachment) => !isPickerOwnedPrAttachment(attachment),
);
if (input.previousPickerPrNumber !== null) {
nextAttachments = nextAttachments.filter(
(attachment) =>
attachment.kind !== "github_pr" || attachment.item.number !== input.previousPickerPrNumber,
);
}
if (input.item.kind === "github-pr") {
if (input.item?.kind === "github-pr") {
const selectedPr = input.item.item;
const hasExistingPrAttachment = nextAttachments.some(
(attachment) =>
attachment.kind === "github_pr" && attachment.item.number === selectedPr.number,
);
if (!hasExistingPrAttachment) {
nextAttachments = [...nextAttachments, { kind: "github_pr", item: selectedPr }];
attachedPrNumber = selectedPr.number;
return [
...nextAttachments,
{
kind: "github_pr",
item: selectedPr,
owner: NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
},
];
}
}
return { attachments: nextAttachments, attachedPrNumber };
return nextAttachments;
}
export function clearPickerPrAttachmentForTargetChange(input: {
attachments: UserComposerAttachment[];
currentTargetId: string;
nextTargetId: string;
}): UserComposerAttachment[] {
if (input.currentTargetId === input.nextTargetId) {
return input.attachments;
}
return syncPickerPrAttachment({ attachments: input.attachments, item: null });
}
export function findCheckoutHintPrAttachment(input: {
@@ -46,11 +46,13 @@ import {
} from "@/runtime/host-runtime";
import { useHostFeature, useHostFeatureMap } from "@/runtime/host-features";
import type { HostProfile } from "@/types/host-connection";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import {
navigateToWorkspace,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
@@ -65,7 +67,6 @@ import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import {
getHostProjectSourceDirectory,
hostProjectFromRoute,
@@ -92,7 +93,11 @@ import {
type PickerCheckoutRequest,
type PickerItem,
} from "./new-workspace-picker-item";
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
import {
clearPickerPrAttachmentForTargetChange,
findCheckoutHintPrAttachment,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import {
resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId,
@@ -179,7 +184,6 @@ interface PickerOptionData {
interface PickerSelection {
item: PickerItem;
attachedPrNumber: number | null;
}
const BRANCH_OPTION_PREFIX = "branch:";
@@ -786,18 +790,6 @@ function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
return [styles.content, styles.contentCentered];
}
function buildNewWorkspaceDraftKey(input: {
selectedServerId: string;
selectedSourceDirectory: string | null;
draftId?: string;
}): string {
const explicitDraftId = input.draftId?.trim();
if (explicitDraftId) {
return `new-workspace:draft:${explicitDraftId}`;
}
return `new-workspace:${input.selectedServerId}:${input.selectedSourceDirectory ?? "choose-project"}`;
}
function getSelectedPickerItem(selection: PickerSelection | null): PickerItem | null {
if (!selection) return null;
return selection.item;
@@ -1181,7 +1173,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
});
navigateToPreparedWorkspaceTab({
navigateToWorkspace({
serverId,
workspaceId,
target: submission.target,
@@ -1682,11 +1674,7 @@ export function NewWorkspaceScreen({
const projectIconDataByProjectKey = useProjectIconDataByProjectKey({
projects: projectIconTargets,
});
const draftKey = buildNewWorkspaceDraftKey({
selectedServerId,
selectedSourceDirectory,
draftId,
});
const draftKey = buildNewWorkspaceDraftKey(draftId);
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo(
@@ -1801,22 +1789,16 @@ export function NewWorkspaceScreen({
}, [selectedItem]);
const selectPickerItem = useCallback(
(item: PickerItem) => {
const next = syncPickerPrAttachment({
const nextAttachments = syncPickerPrAttachment({
attachments: chatDraft.attachments,
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
item,
});
setManualPickerSelection({
item,
attachedPrNumber: next.attachedPrNumber,
});
if (next.attachments !== chatDraft.attachments) {
chatDraft.setAttachments(next.attachments);
}
setManualPickerSelection({ item });
chatDraft.setAttachments(nextAttachments);
setPickerOpen(false);
},
[chatDraft, manualPickerSelection?.attachedPrNumber],
[chatDraft],
);
const handleSelectOption = useCallback(
@@ -1828,6 +1810,20 @@ export function NewWorkspaceScreen({
[itemById, selectPickerItem],
);
const clearManualPickerSelectionForTargetChange = useCallback(
(currentTargetId: string, nextTargetId: string) => {
const nextAttachments = clearPickerPrAttachmentForTargetChange({
attachments: chatDraft.attachments,
currentTargetId,
nextTargetId,
});
if (nextAttachments === chatDraft.attachments) return;
chatDraft.setAttachments(nextAttachments);
setManualPickerSelection(null);
},
[chatDraft],
);
const handleSelectProjectOption = useCallback(
(id: string) => {
// selectProjectOption enforces selectability (worktree-only when
@@ -1835,9 +1831,17 @@ export function NewWorkspaceScreen({
// canCreateWorktree or non-git projects become unselectable.
selectProjectOption(id);
setProjectPickerOpen(false);
setManualPickerSelection(null);
clearManualPickerSelectionForTargetChange(selectedProjectOptionId, id);
},
[selectProjectOption],
[clearManualPickerSelectionForTargetChange, selectProjectOption, selectedProjectOptionId],
);
const handleSelectWorkspaceHost = useCallback(
(id: string) => {
handleSelectHost(id);
clearManualPickerSelectionForTargetChange(selectedServerId, id);
},
[clearManualPickerSelectionForTargetChange, handleSelectHost, selectedServerId],
);
const handleAddProject = useCallback(() => {
@@ -2041,7 +2045,7 @@ export function NewWorkspaceScreen({
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) =>
navigateToWorkspace(targetServerId, workspaceId),
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
});
return;
}
@@ -2155,7 +2159,7 @@ export function NewWorkspaceScreen({
host: {
allHosts,
selectedServerId,
onSelect: handleSelectHost,
onSelect: handleSelectWorkspaceHost,
openState: hostPickerOpen,
onOpenChange: handleHostPickerOpenChange,
anchorRef: hostPickerAnchorRef,
+18
View File
@@ -1,9 +1,27 @@
import { generateMessageId } from "@/types/stream";
export const NEW_WORKSPACE_DRAFT_KEY = "new-workspace";
const NEW_WORKSPACE_FORK_DRAFT_PREFIX = `${NEW_WORKSPACE_DRAFT_KEY}:draft:`;
export function generateDraftId(): string {
return `draft_${generateMessageId()}`;
}
export function buildNewWorkspaceDraftKey(draftId?: string): string {
const explicitDraftId = draftId?.trim();
if (explicitDraftId) {
return `${NEW_WORKSPACE_FORK_DRAFT_PREFIX}${explicitDraftId}`;
}
return NEW_WORKSPACE_DRAFT_KEY;
}
export function isLegacyNewWorkspaceDraftKey(draftKey: string): boolean {
return (
draftKey.startsWith(`${NEW_WORKSPACE_DRAFT_KEY}:`) &&
!draftKey.startsWith(NEW_WORKSPACE_FORK_DRAFT_PREFIX)
);
}
export function buildDraftStoreKey(input: {
serverId: string;
agentId: string;
@@ -1,11 +1,60 @@
import { describe, expect, it } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { migratePersistedState, type MigrateLegacyImages } from "./migration";
import { isAttachmentMetadata } from "./state";
import { isAttachmentMetadata, type DraftRecord } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata);
function activeDraft(
text: string,
updatedAt: number,
attachments: UserComposerAttachment[] = [],
): DraftRecord {
return {
input: { text, attachments },
lifecycle: "active",
updatedAt,
version: 1,
};
}
function githubIssueAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_issue" }> {
return {
kind: "github_issue",
item: {
kind: "issue",
number,
title: `Review item ${number}`,
url: `https://example.com/issues/${number}`,
state: "open",
body: null,
labels: [],
},
};
}
function githubPrAttachment(
number: number,
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
return {
kind: "github_pr",
item: {
kind: "pr",
number,
title: `Review item ${number}`,
url: `https://example.com/pulls/${number}`,
state: "open",
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/legacy",
},
};
}
function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "review" }> {
return {
kind: "review",
@@ -47,6 +96,57 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
}
describe("draft-store migration", () => {
it("promotes the newest legacy New Workspace draft into the singleton surface", async () => {
const forkDraft = activeDraft("fork context", 1700000000003);
const agentDraft = activeDraft("agent prompt", 1700000000004);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/older": activeDraft(
"older new workspace prompt",
1700000000001,
),
"new-workspace:server-b:/project/newer": activeDraft(
"newer new workspace prompt",
1700000000002,
),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000005 },
);
expect(migrated.drafts).toEqual({
"new-workspace": activeDraft("newer new workspace prompt", 1700000000002),
"new-workspace:draft:fork-1": forkDraft,
"agent:server-a:agent-1": agentDraft,
});
});
it("drops unowned checkout PR context when promoting a scoped New Workspace draft", async () => {
const issue = githubIssueAttachment(101);
const migrated = await migratePersistedState(
{
drafts: {
"new-workspace:server-a:/project/a": activeDraft("keep the prompt", 2, [
issue,
githubPrAttachment(202),
]),
},
createModalDraft: null,
},
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 3 },
);
expect(migrated.drafts["new-workspace"]?.input).toEqual({
text: "keep the prompt",
attachments: [issue],
});
});
it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => {
const migrated = await migratePersistedState(
{
@@ -1,4 +1,5 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys";
import {
isAttachmentMetadata,
isLegacyDraftImage,
@@ -98,6 +99,46 @@ async function buildMigratedDraftRecord(
};
}
function migrateNewWorkspaceDraftKeys(
drafts: Record<string, DraftRecord>,
): Record<string, DraftRecord> {
const legacyEntries = Object.entries(drafts).filter(([draftKey]) =>
isLegacyNewWorkspaceDraftKey(draftKey),
);
if (legacyEntries.length === 0) {
return drafts;
}
const nextDrafts = { ...drafts };
for (const [draftKey] of legacyEntries) {
delete nextDrafts[draftKey];
}
if (nextDrafts[NEW_WORKSPACE_DRAFT_KEY]) {
return nextDrafts;
}
const newestActiveDraft = legacyEntries
.map(([, draft]) => draft)
.filter((draft) => draft.lifecycle === "active")
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
if (newestActiveDraft) {
// Legacy scoped drafts did not record whether a PR came from the Base
// picker. That checkout context is unsafe to carry onto a global surface.
nextDrafts[NEW_WORKSPACE_DRAFT_KEY] = {
...newestActiveDraft,
input: {
...newestActiveDraft.input,
attachments: newestActiveDraft.input.attachments.filter(
(attachment) => attachment.kind !== "github_pr",
),
},
};
}
return nextDrafts;
}
export async function migratePersistedState(
state: unknown,
ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number },
@@ -129,7 +170,8 @@ export async function migratePersistedState(
}
return {
drafts: nextDrafts,
// COMPAT(newWorkspaceDraftSingleton): migrated in v0.1.108; remove after 2027-01-13.
drafts: migrateNewWorkspaceDraftKeys(nextDrafts),
createModalDraft,
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyClearDraftRecord, pruneFinalizedDraftRecords } from "./state";
import { applyClearDraftRecord, pruneFinalizedDraftRecords, toDraftInputIfReady } from "./state";
describe("draft-store lifecycle", () => {
it("prunes finalized tombstones after TTL", () => {
@@ -69,3 +69,35 @@ describe("draft-store lifecycle", () => {
});
});
});
describe("draft-store normalization", () => {
it("preserves New Workspace picker ownership when hydrating a draft", () => {
const pickerAttachment = {
kind: "github_pr" as const,
owner: "new-workspace-picker" as const,
item: {
kind: "pr" as const,
number: 202,
title: "Persist picker ownership",
url: "https://example.com/pull/202",
state: "open" as const,
body: null,
labels: [],
baseRefName: "main",
headRefName: "feature/picker-ownership",
},
};
expect(
toDraftInputIfReady({
input: { text: "Keep this prompt", attachments: [pickerAttachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({
text: "Keep this prompt",
attachments: [pickerAttachment],
});
});
});
+22 -2
View File
@@ -1,7 +1,11 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import {
NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER,
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 4;
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
export interface LegacyDraftImage {
@@ -82,6 +86,13 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
if (record.kind !== "github_issue" && record.kind !== "github_pr") {
return false;
}
if (
record.kind === "github_pr" &&
record.owner !== undefined &&
record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
) {
return false;
}
return GitHubSearchItemSchema.safeParse(record.item).success;
}
@@ -94,6 +105,15 @@ export function normalizeComposerAttachment(
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
if (attachment.kind === "github_pr") {
return {
kind: "github_pr",
item: attachment.item,
...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
? { owner: attachment.owner }
: {}),
};
}
return attachment;
}
@@ -11,6 +11,7 @@ import {
navigateToLastWorkspace as navigateToLastWorkspacePure,
navigateToWorkspace as navigateToWorkspacePure,
parseActiveWorkspaceSelection,
type NavigateToWorkspaceInput,
type NavigateToWorkspaceDeps,
} from "./navigation";
import { useSessionStore } from "@/stores/session-store";
@@ -19,6 +20,7 @@ import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/ut
import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navigation";
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export type { NavigateToWorkspaceInput } from "./navigation";
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
@@ -34,9 +36,10 @@ function navigateDeps(): NavigateToWorkspaceDeps {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
openWorkspaceAgentTab: (workspaceKey, agentId) => {
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { kind: "agent", agentId });
},
openTabFocused: (workspaceKey, target) =>
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, target),
pinAgent: (workspaceKey, agentId) =>
useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId),
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => {
navigateToHostWorkspaceRoute(route);
@@ -57,8 +60,8 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated();
}
export function navigateToWorkspace(serverId: string, workspaceId: string) {
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
export function navigateToWorkspace(input: NavigateToWorkspaceInput): string {
return navigateToWorkspacePure(input, navigateDeps());
}
export function navigateToLastWorkspace(): boolean {
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import {
navigateToLastWorkspace,
navigateToWorkspace,
@@ -9,25 +10,28 @@ import {
} from "./navigation";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
interface RecordedAgentTab {
interface RecordedTab {
workspaceKey: string;
agentId: string;
target: WorkspaceTabTarget;
}
function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
const navigations: string[] = [];
const remembered: ActiveWorkspaceSelection[] = [];
const openedAgentTabs: RecordedAgentTab[] = [];
const openedTabs: RecordedTab[] = [];
const deps: NavigateToWorkspaceDeps = {
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
openWorkspaceAgentTab: (workspaceKey, agentId) =>
openedAgentTabs.push({ workspaceKey, agentId }),
openTabFocused: (workspaceKey, target) => {
openedTabs.push({ workspaceKey, target });
return target.kind === "agent" ? target.agentId : null;
},
pinAgent: () => undefined,
rememberLastWorkspace: (selection) => remembered.push(selection),
navigateToRoute: (route) => navigations.push(route),
...overrides,
};
return { deps, navigations, remembered, openedAgentTabs };
return { deps, navigations, remembered, openedTabs };
}
function createLastSelectionDeps(
@@ -63,7 +67,7 @@ describe("workspace navigation", () => {
it("navigates to a workspace route and remembers the selection", () => {
const { deps, navigations, remembered } = createFakeDeps();
navigateToWorkspace("server-1", "workspace-a", deps);
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a"]);
expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]);
@@ -81,14 +85,53 @@ describe("workspace navigation", () => {
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedAgentTabs } = createFakeDeps({
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace("server-1", "workspace-a", deps);
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(openedAgentTabs).toEqual([{ workspaceKey: "server-1:workspace-a", agentId: "agent-1" }]);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "agent", agentId: "agent-1" },
},
]);
});
it("keeps an explicit tab authoritative over an attention agent", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace(
{
serverId: "server-1",
workspaceId: "workspace-a",
target: { kind: "draft", draftId: "draft-1" },
},
deps,
);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "draft", draftId: "draft-1" },
},
]);
});
it("reads the active workspace from the current route", () => {
@@ -10,6 +10,8 @@ import {
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
export interface RouteSelectionInput {
pathname: string;
@@ -19,10 +21,16 @@ export interface RouteSelectionInput {
};
}
export interface NavigateToWorkspaceDeps {
export interface NavigateToWorkspaceInput {
serverId: string;
workspaceId: string;
target?: WorkspaceTabTarget;
pin?: boolean;
}
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
openWorkspaceAgentTab: (workspaceKey: string, agentId: string) => void;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
}
@@ -71,27 +79,35 @@ export function parseActiveWorkspaceSelection(
}
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
input: NavigateToWorkspaceInput,
deps: NavigateToWorkspaceDeps,
): void {
const workspaces = deps.getSessionWorkspaces(serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openWorkspaceAgentTab(`${serverId}:${resolvedWorkspaceId}`, attentionAgentId);
): string {
if (input.target) {
prepareWorkspaceTab({ ...input, target: input.target }, deps);
} else {
const workspaces = deps.getSessionWorkspaces(input.serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: input.workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
deps.openTabFocused(`${input.serverId}:${resolvedWorkspaceId}`, {
kind: "agent",
agentId: attentionAgentId,
});
}
}
deps.rememberLastWorkspace({ serverId, workspaceId });
deps.navigateToRoute(buildHostWorkspaceRoute(serverId, workspaceId));
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
deps.navigateToRoute(route);
return route;
}
export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): boolean {
@@ -99,6 +115,6 @@ export function navigateToLastWorkspace(deps: NavigateToLastWorkspaceDeps): bool
if (!selection) {
return false;
}
navigateToWorkspace(selection.serverId, selection.workspaceId, deps);
navigateToWorkspace(selection, deps);
return true;
}
@@ -2,7 +2,7 @@ import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
export type { NavigateToAgentInput } from "./resolve";
@@ -69,7 +69,7 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
navigateToHostAgent: (route) => {
router.navigate(route as Href);
},
navigateToPreparedWorkspaceTab,
navigateToWorkspace,
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
restoreArchivedWorkspace(serverId, agentId, workspaceId);
},
@@ -4,7 +4,7 @@ import {
type AgentNavTarget,
type NavigateToAgentDeps,
} from "@/utils/navigate-to-agent/resolve";
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
@@ -14,7 +14,7 @@ interface RecordedHostNav {
route: string;
}
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
interface RecordedTabNav extends NavigateToWorkspaceInput {}
interface RecordedRestore {
serverId: string;
@@ -40,7 +40,7 @@ function createFakeNavigators(target: AgentNavTarget): {
navigateToHostAgent: (route) => {
hostNavigations.push({ route });
},
navigateToPreparedWorkspaceTab: (input) => {
navigateToWorkspace: (input) => {
tabNavigations.push(input);
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
},
@@ -1,6 +1,6 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
export interface NavigateToAgentInput {
serverId: string;
@@ -18,7 +18,7 @@ export interface AgentNavTarget {
export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
navigateToHostAgent: (route: string) => void;
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
restoreArchivedWorkspace: (input: {
serverId: string;
agentId: string;
@@ -49,7 +49,7 @@ export function resolveNavigateToAgent(
workspaceId,
});
return deps.navigateToPreparedWorkspaceTab({
return deps.navigateToWorkspace({
serverId: input.serverId,
workspaceId,
target: { kind: "agent", agentId: input.agentId },
@@ -9,8 +9,8 @@ vi.mock("expo-router", () => ({
router: { navigate: vi.fn() },
}));
vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: vi.fn(() => ""),
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: vi.fn(() => ""),
}));
vi.mock("@/runtime/host-runtime", () => ({
@@ -3,7 +3,6 @@ import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
export interface PrepareWorkspaceTabInput {
serverId: string;
@@ -12,17 +11,11 @@ export interface PrepareWorkspaceTabInput {
pin?: boolean;
}
export type NavigateToPreparedWorkspaceTabInput = PrepareWorkspaceTabInput;
export interface PrepareWorkspaceTabDeps {
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
pinAgent: (workspaceKey: string, agentId: string) => void;
}
export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabDeps {
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
}
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
if (target.kind !== "draft" || target.draftId.trim() !== "new") {
return target;
@@ -33,7 +26,7 @@ function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
export function prepareWorkspaceTab(
input: PrepareWorkspaceTabInput,
deps: PrepareWorkspaceTabDeps,
): string {
): void {
const target = getPreparedTarget(input.target);
const key =
buildWorkspaceTabPersistenceKey({
@@ -46,15 +39,4 @@ export function prepareWorkspaceTab(
if (input.pin && target.kind === "agent") {
deps.pinAgent(key, target.agentId);
}
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
}
export function navigateToPreparedWorkspaceTab(
input: NavigateToPreparedWorkspaceTabInput,
deps: NavigateToPreparedWorkspaceTabDeps,
): string {
const route = prepareWorkspaceTab(input, deps);
deps.navigateToWorkspace(input.serverId, input.workspaceId);
return route;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
import { prepareWorkspaceTab } from "@/utils/prepare-workspace-tab";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
@@ -16,11 +16,6 @@ interface RecordedPin {
agentId: string;
}
interface RecordedNavigation {
serverId: string;
workspaceId: string;
}
function createFakeLayout() {
const openedTabs: RecordedOpenedTab[] = [];
const pinnedAgents: RecordedPin[] = [];
@@ -37,21 +32,11 @@ function createFakeLayout() {
};
}
function createFakeNavigator() {
const navigations: RecordedNavigation[] = [];
return {
navigations,
navigateToWorkspace: (serverId: string, workspaceId: string) => {
navigations.push({ serverId, workspaceId });
},
};
}
describe("prepareWorkspaceTab", () => {
it("opens and focuses an agent tab", () => {
const layout = createFakeLayout();
const route = prepareWorkspaceTab(
prepareWorkspaceTab(
{
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
@@ -60,30 +45,9 @@ describe("prepareWorkspaceTab", () => {
layout,
);
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(layout.openedTabs).toEqual([
{ key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
]);
expect(layout.pinnedAgents).toEqual([]);
});
it("prepares a tab and navigates through the workspace navigation helper", () => {
const layout = createFakeLayout();
const navigator = createFakeNavigator();
const route = navigateToPreparedWorkspaceTab(
{
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
},
{ ...layout, navigateToWorkspace: navigator.navigateToWorkspace },
);
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(layout.openedTabs).toEqual([
{ key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
]);
expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }]);
});
});
+3 -16
View File
@@ -1,16 +1,10 @@
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
prepareWorkspaceTab as prepareWorkspaceTabPure,
navigateToPreparedWorkspaceTab as navigateToPreparedWorkspaceTabPure,
type PrepareWorkspaceTabInput,
type NavigateToPreparedWorkspaceTabInput,
} from "./prepare-workspace-tab";
export type {
PrepareWorkspaceTabInput,
NavigateToPreparedWorkspaceTabInput,
} from "./prepare-workspace-tab";
export type { PrepareWorkspaceTabInput } from "./prepare-workspace-tab";
function layoutStoreDeps() {
const store = useWorkspaceLayoutStore.getState();
@@ -20,13 +14,6 @@ function layoutStoreDeps() {
};
}
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
return prepareWorkspaceTabPure(input, layoutStoreDeps());
}
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
return navigateToPreparedWorkspaceTabPure(input, {
...layoutStoreDeps(),
navigateToWorkspace,
});
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): void {
prepareWorkspaceTabPure(input, layoutStoreDeps());
}
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
describe("agent timeline content", () => {
test("limits terminal input to the tool-call content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: oversizedInput,
icon: "square_terminal",
},
});
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024),
icon: "square_terminal",
},
});
});
});
@@ -24,8 +24,27 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
};
}
function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "plain_text" ||
typeof item.detail.text !== "string" ||
item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
return {
...item,
detail: {
...item.detail,
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item);
item = limitPlainText(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
@@ -31,6 +31,8 @@ import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextPermission,
waitForNextTimelineItem,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -554,6 +556,215 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("shows a successful shell command that produces no output", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentCommand({
threadId: "thread-1",
callId: "silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
appServer.says({ threadId: "thread-1", text: "Merged." });
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows a silent shell command from legacy live notifications", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentLegacyCommand({
threadId: "thread-1",
callId: "legacy-silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "legacy-silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows the exact bytes Codex writes into an existing terminal", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "gh pr merge 2030 --squash\n",
});
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
const relabeledTerminal = waitForTimelineToolCall(session, "terminal-session-4242-1");
appServer.runsLegacyCommand({
threadId: "thread-1",
callId: "interactive-shell",
command: "sleep 30",
output: "Process running with session id 4242",
});
await expect(relabeledTerminal).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "sleep 30",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps repeated writes to one terminal as separate timeline rows", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const firstTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git status\n",
});
const secondTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git push\n",
});
const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]);
expect(first.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-1",
detail: { type: "plain_text", text: "git status\n" },
});
expect(second.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-2",
detail: { type: "plain_text", text: "git push\n" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("surfaces an MCP elicitation and returns Codex's required approval action", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
@@ -1514,24 +1514,23 @@ export function mapCodexPatchNotificationToToolCall(params: {
}
function mapCodexTerminalInteractionToToolCall(params: {
callId: string;
processId?: string | null;
fallbackCallId?: string | null;
command?: string | null;
stdin?: string | null;
}): ToolCallTimelineItem {
const processId = nonEmptyString(params.processId ?? undefined);
const callId = processId
? `terminal-session-${processId}`
: (nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction");
const label = nonEmptyString(params.command ?? undefined);
return {
type: "tool_call",
callId,
callId: params.callId,
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
...(label ? { label } : {}),
...(params.stdin !== null && params.stdin !== undefined ? { text: params.stdin } : {}),
icon: "square_terminal",
},
...(processId ? { metadata: { processId } } : {}),
@@ -2111,8 +2110,8 @@ const CodexEventExecCommandEndNotificationSchema = z
cwd: z.string().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
aggregated_output: z.string().optional(),
aggregatedOutput: z.string().optional(),
aggregated_output: z.string().nullable().optional(),
aggregatedOutput: z.string().nullable().optional(),
formatted_output: z.string().optional(),
exit_code: z.number().nullable().optional(),
exitCode: z.number().nullable().optional(),
@@ -3102,7 +3101,11 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
private pendingUnlabeledTerminalInteractions = new Set<string>();
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
>();
private nextTerminalInteractionOrdinal = 0;
private emittedTerminalInteractionKeys = new Set<string>();
private emittedExecCommandStartedCallIds = new Set<string>();
private emittedExecCommandCompletedCallIds = new Set<string>();
@@ -5447,13 +5450,18 @@ export class CodexAppServerAgentSession implements AgentSession {
const command =
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
null;
const callId = this.createTerminalInteractionCallId(parsed.processId, parsed.callId);
if (!command && parsed.processId) {
this.pendingUnlabeledTerminalInteractions.add(parsed.processId);
const pendingInteractions =
this.pendingUnlabeledTerminalInteractions.get(parsed.processId) ?? [];
pendingInteractions.push({ callId, stdin: parsed.stdin });
this.pendingUnlabeledTerminalInteractions.set(parsed.processId, pendingInteractions);
}
const timelineItem = mapCodexTerminalInteractionToToolCall({
callId,
processId: parsed.processId,
fallbackCallId: parsed.callId,
command,
stdin: parsed.stdin,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
@@ -5862,15 +5870,31 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
return;
}
const pendingInteractions = this.pendingUnlabeledTerminalInteractions.get(processId) ?? [];
this.pendingUnlabeledTerminalInteractions.delete(processId);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexTerminalInteractionToToolCall({
processId,
command: displayCommand,
}),
});
for (const pendingInteraction of pendingInteractions) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexTerminalInteractionToToolCall({
callId: pendingInteraction.callId,
processId,
command: displayCommand,
stdin: pendingInteraction.stdin,
}),
});
}
}
private createTerminalInteractionCallId(
processId: string | null,
fallbackCallId: string | null,
): string {
const baseCallId = processId
? `terminal-session-${processId}`
: (nonEmptyString(fallbackCallId ?? undefined) ?? "terminal-interaction");
this.nextTerminalInteractionOrdinal += 1;
return `${baseCallId}-${this.nextTerminalInteractionOrdinal}`;
}
private shouldEmitTerminalInteractionKey(key: string): boolean {
@@ -19,6 +19,19 @@ interface FakeLegacyCommand {
command: string;
output: string;
}
interface FakeSilentCommand {
threadId: string;
callId: string;
command: string;
cwd: string;
}
interface FakeTerminalInput {
threadId: string;
turnId: string;
itemId: string;
processId: string;
text: string;
}
interface FakeLegacyPatch {
threadId: string;
callId: string;
@@ -51,6 +64,9 @@ export interface FakeCodexAppServer {
runsLegacyCommand(params: FakeLegacyCommand): void;
appliesLegacyPatch(params: FakeLegacyPatch): void;
completesCommand(params: FakeLegacyCommand): void;
completesSilentCommand(params: FakeSilentCommand): void;
completesSilentLegacyCommand(params: FakeSilentCommand): void;
typesIntoTerminal(params: FakeTerminalInput): void;
says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void;
requestCommandApproval(params: {
itemId: string;
@@ -366,6 +382,37 @@ export function createFakeCodexAppServer(
exitCode: 0,
});
},
completesSilentCommand(params) {
completeItem(params.threadId, {
type: "commandExecution",
id: params.callId,
status: "completed",
command: params.command,
cwd: params.cwd,
aggregatedOutput: null,
exitCode: 0,
});
},
completesSilentLegacyCommand(params) {
writeLegacyEvent(params.threadId, "codex/event/exec_command_end", {
type: "exec_command_end",
call_id: params.callId,
command: params.command,
cwd: params.cwd,
aggregatedOutput: null,
exit_code: 0,
success: true,
});
},
typesIntoTerminal(params) {
writeNotification("item/commandExecution/terminalInteraction", {
threadId: params.threadId,
turnId: params.turnId,
itemId: params.itemId,
processId: params.processId,
stdin: params.text,
});
},
says(params) {
if (params.itemId) {
for (const chunk of params.chunks ?? [params.text]) {
@@ -458,21 +505,53 @@ function toJsonObject(value: unknown): JsonObject {
return {};
}
export function waitForNextPermission(
type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
reject(new Error(`Timed out waiting for ${type}`));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") {
if (event.type !== type) {
return;
}
const typedEvent = event as StreamEventOfType<TType>;
if (accepts && !accepts(typedEvent)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(event);
resolve(typedEvent);
});
});
}
type TimelineEvent = StreamEventOfType<"timeline">;
export function waitForNextPermission(
session: AgentSession,
): Promise<StreamEventOfType<"permission_requested">> {
return waitForNextEvent(session, "permission_requested");
}
export function waitForNextTimelineItem(session: AgentSession): Promise<TimelineEvent> {
return waitForNextEvent(session, "timeline");
}
export function waitForTimelineToolCall(
session: AgentSession,
callId: string,
): Promise<TimelineEvent> {
return waitForNextEvent(
session,
"timeline",
(event) => event.item.type === "tool_call" && event.item.callId === callId,
);
}
@@ -141,7 +141,7 @@ const CodexCommandExecutionItemSchema = z
error: z.unknown().optional(),
command: CodexCommandValueSchema.optional(),
cwd: z.string().optional(),
aggregatedOutput: z.string().optional(),
aggregatedOutput: z.string().nullable().optional(),
exitCode: z.number().nullable().optional(),
})
.passthrough();
@@ -694,7 +694,7 @@ function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>,
): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput ?? undefined);
const input = toNullableObject({
...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
+59
View File
@@ -0,0 +1,59 @@
import { spawnSync } from "node:child_process";
import { resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_ATTEMPTS = 3;
const DEFAULT_BACKOFF_MS = 20_000;
function runNpm(args) {
const command = process.platform === "win32" ? "npm.cmd" : "npm";
const result = spawnSync(command, args, {
shell: process.platform === "win32",
stdio: "inherit",
});
if (result.error) {
console.error(`Failed to start npm: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
function wait(delayMs) {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
export async function runWithRetry(
args,
{ attempts = DEFAULT_ATTEMPTS, backoffMs = DEFAULT_BACKOFF_MS, run = runNpm, sleep = wait } = {},
) {
let exitCode = 1;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
exitCode = await run(args);
if (exitCode === 0) {
return 0;
}
if (attempt < attempts) {
const delayMs = attempt * backoffMs;
console.warn(
`npm failed with exit code ${exitCode}; retrying in ${delayMs / 1000}s (${attempt + 1}/${attempts})`,
);
await sleep(delayMs);
}
}
return exitCode;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolvePath(process.argv[1])) {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: node scripts/npm-retry.mjs <npm arguments...>");
process.exitCode = 2;
} else {
process.exitCode = await runWithRetry(args);
}
}