From 2828dce65f93cb2472b881cbcae58cbc23e1c2a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 11:45:32 +0800 Subject: [PATCH 1/2] fix(app): advance multi-question prompts step by step (#1462) --- packages/app/e2e/helpers/questions.ts | 41 +++++++ .../e2e/question-prompt-pagination.spec.ts | 54 +++++++++ .../app/src/components/question-form-card.tsx | 33 ++++-- .../providers/mock-load-test-agent.test.ts | 68 +++++++++++ .../agent/providers/mock-load-test-agent.ts | 111 +++++++++++++----- 5 files changed, 270 insertions(+), 37 deletions(-) diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/helpers/questions.ts index 93f0b4442..92c64e94a 100644 --- a/packages/app/e2e/helpers/questions.ts +++ b/packages/app/e2e/helpers/questions.ts @@ -44,6 +44,18 @@ export async function openQuestion( .click(); } +export async function expectQuestionNavigationEnabled( + page: Page, + input: { index: number; total: number }, +): Promise { + await expect( + page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: `Question ${input.index} of ${input.total}` }), + ).toBeEnabled(); +} + export async function fillQuestionAnswer( page: Page, input: { question: string; answer: string }, @@ -59,3 +71,32 @@ export async function submitQuestionAnswers(page: Page): Promise { await page.getByTestId("question-form-primary-action").click(); await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 }); } + +export async function expectQuestionPrimaryActionEnabled(page: Page, label: string): Promise { + await expect( + page.getByTestId("question-form-card").first().getByRole("button", { name: label }), + ).toBeEnabled(); +} + +export async function expectQuestionPrimaryActionDisabled( + page: Page, + label: string, +): Promise { + await expect( + page.getByTestId("question-form-card").first().getByRole("button", { name: label }), + ).toBeDisabled(); +} + +export async function expectQuestionDismissEnabled(page: Page): Promise { + await expect( + page.getByTestId("question-form-card").first().getByRole("button", { name: "Dismiss" }), + ).toBeEnabled(); +} + +export async function continueToNextQuestion(page: Page): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: "Next" }) + .click(); +} diff --git a/packages/app/e2e/question-prompt-pagination.spec.ts b/packages/app/e2e/question-prompt-pagination.spec.ts index 60f8520f0..a2d5747fd 100644 --- a/packages/app/e2e/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/question-prompt-pagination.spec.ts @@ -2,9 +2,14 @@ import { test } from "./fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; import { chooseQuestionOption, + continueToNextQuestion, expectCurrentQuestion, + expectQuestionDismissEnabled, expectQuestionHidden, + expectQuestionNavigationEnabled, expectQuestionOptionSelected, + expectQuestionPrimaryActionDisabled, + expectQuestionPrimaryActionEnabled, fillQuestionAnswer, openQuestion, submitQuestionAnswers, @@ -15,6 +20,8 @@ const TOTAL_QUESTIONS = 3; const SURFACE_QUESTION = "Which surface should this apply to?"; const ROLLOUT_QUESTION = "Which rollout should we use?"; const SUCCESS_QUESTION = "What success criteria should we use?"; +const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?"; +const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?"; test.describe("Question prompt pagination", () => { test("shows one question at a time with numbered navigation", async ({ page }) => { @@ -70,4 +77,51 @@ test.describe("Question prompt pagination", () => { await session.cleanup(); } }); + + test("free-write questions use Next before final Submit", async ({ page }) => { + test.setTimeout(180_000); + + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-free-write-", + title: "Question free-write e2e", + initialPrompt: "Emit synthetic questions: two free-write questions.", + }); + + try { + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + + await expectCurrentQuestion(page, { + index: 1, + total: 2, + question: REPO_URL_QUESTION, + }); + + await fillQuestionAnswer(page, { + question: REPO_URL_QUESTION, + answer: "git@github.com:user/private-repo.git", + }); + + await expectQuestionPrimaryActionEnabled(page, "Next"); + await expectQuestionDismissEnabled(page); + await expectQuestionNavigationEnabled(page, { index: 2, total: 2 }); + + await continueToNextQuestion(page); + await expectCurrentQuestion(page, { + index: 2, + total: 2, + question: COMMIT_MESSAGE_QUESTION, + }); + await expectQuestionPrimaryActionDisabled(page, "Submit"); + + await fillQuestionAnswer(page, { + question: COMMIT_MESSAGE_QUESTION, + answer: "Initialize private repo", + }); + await expectQuestionPrimaryActionEnabled(page, "Submit"); + await submitQuestionAnswers(page); + } finally { + await session.cleanup(); + } + }); }); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 29cfe681f..1179d55a7 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -16,6 +16,7 @@ import { isWeb } from "@/constants/platform"; import { areQuestionsAnswered, buildQuestionFormAnswers, + isQuestionAnswered, parseQuestionFormQuestions, questionShowsTextInput, resolveDismissLabel, @@ -296,6 +297,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ? Math.min(activeQuestionIndex, questions.length - 1) : 0; const activeQuestion = questions?.[resolvedActiveQuestionIndex]; + const activeQuestionAnswered = activeQuestion + ? isQuestionAnswered(activeQuestion, resolvedActiveQuestionIndex, selections, otherTexts) + : false; + const isLastQuestion = questions ? resolvedActiveQuestionIndex === questions.length - 1 : true; const handleSubmit = useCallback(() => { if (!questions || !allAnswered || isResponding) return; @@ -340,6 +345,15 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi setActiveQuestionIndex(index); }, []); + const handlePrimaryAction = useCallback(() => { + if (!isLastQuestion) { + if (!activeQuestionAnswered || isResponding) return; + setActiveQuestionIndex((index) => Math.min(index + 1, (questions?.length ?? 1) - 1)); + return; + } + handleSubmit(); + }, [activeQuestionAnswered, handleSubmit, isLastQuestion, isResponding, questions?.length]); + const dismissButtonStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, @@ -352,18 +366,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent], ); - const submitDisabled = !allAnswered || isResponding; + const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered); + const primaryActionLabel = isLastQuestion ? "Submit" : "Next"; const submitButtonStyle = useCallback( ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, - opacity: submitDisabled ? 0.5 : 1, + opacity: primaryDisabled ? 0.5 : 1, }, - pressed && !submitDisabled ? styles.optionItemPressed : null, + pressed && !primaryDisabled ? styles.optionItemPressed : null, ], - [submitDisabled, theme.colors.accent], + [primaryDisabled, theme.colors.accent], ); const containerStyle = useMemo( @@ -458,7 +473,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi placeholder={getQuestionInputPlaceholder(activeQuestion)} isResponding={isResponding} onChange={setOtherText} - onSubmit={handleSubmit} + onSubmit={handlePrimaryAction} /> ) : null} @@ -485,10 +500,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi {respondingAction === "submit" ? ( @@ -496,7 +511,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ) : ( - Submit + {primaryActionLabel} )} diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 63c782d75..d27e17666 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -11,6 +11,20 @@ import { MockLoadTestAgentClient, } from "./mock-load-test-agent.js"; +type PermissionRequestedEvent = Extract; + +function expectSinglePermissionRequest(events: AgentStreamEvent[]): PermissionRequestedEvent { + const permissions = events.filter( + (event): event is PermissionRequestedEvent => event.type === "permission_requested", + ); + expect(permissions).toHaveLength(1); + const [permission] = permissions; + if (!permission) { + throw new Error("permission request missing"); + } + return permission; +} + describe("MockLoadTestAgentClient", () => { afterEach(() => { vi.useRealTimers(); @@ -131,6 +145,60 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); + test("emits the free-write question scenario selected by prompt", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + const resultPromise = session.run("Emit synthetic questions: two free-write questions."); + await vi.advanceTimersByTimeAsync(0); + + const permission = expectSinglePermissionRequest(events); + expect(permission.request).toMatchObject({ + provider: "mock", + name: "MockQuestions", + kind: "question", + input: { + questions: [ + { + question: "What is the GitHub private repo URL to push to?", + header: "repoUrl", + options: [], + multiSelect: false, + }, + { + question: "What should the first commit message be?", + header: "commitMessage", + options: [], + multiSelect: false, + }, + ], + }, + }); + + await session.respondToPermission(permission.request.id, { + behavior: "allow", + updatedInput: { + answers: { + repoUrl: "git@github.com:user/private-repo.git", + commitMessage: "Initialize private repo", + }, + }, + }); + await expect(resultPromise).resolves.toMatchObject({ + sessionId: session.id, + finalText: "Synthetic questions resolved", + canceled: false, + }); + unsubscribe(); + }); + test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => { vi.useFakeTimers(); const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-")); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index c5dd98e88..e561213d8 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -127,12 +127,80 @@ interface AgentStreamStressRequest { coalesced: boolean; } +interface MockQuestionOption { + label: string; + description?: string; +} + +interface MockQuestionPromptQuestion { + question: string; + header: string; + options: MockQuestionOption[]; + multiSelect: boolean; + allowOther?: boolean; + allowEmpty?: boolean; + placeholder?: string; + dismissLabel?: string; +} + +interface MockQuestionPromptRequest { + questions: MockQuestionPromptQuestion[]; +} + function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } -function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean { - return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt)); +function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { + const text = promptToText(prompt); + if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { + return null; + } + + if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) { + return { + questions: [ + { + question: "What is the GitHub private repo URL to push to?", + header: "repoUrl", + options: [], + multiSelect: false, + placeholder: "git@github.com:user/repo.git", + }, + { + question: "What should the first commit message be?", + header: "commitMessage", + options: [], + multiSelect: false, + placeholder: "Initial commit", + }, + ], + }; + } + + return { + questions: [ + { + question: "Which surface should this apply to?", + header: "surface", + options: [{ label: "App" }, { label: "Desktop" }], + multiSelect: false, + }, + { + question: "Which rollout should we use?", + header: "rollout", + options: [{ label: "Immediately" }, { label: "Behind feature flag" }], + multiSelect: false, + }, + { + question: "What success criteria should we use?", + header: "success", + options: [], + multiSelect: false, + placeholder: "Describe success...", + }, + ], + }; } function resolveModelProfile(modelId: string | null | undefined): { @@ -536,10 +604,11 @@ export class MockLoadTestAgentSession implements AgentSession { const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); const stress = parseAgentStreamStressPrompt(prompt); + const questionPrompt = parseMockQuestionPrompt(prompt); if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); - } else if (shouldEmitQuestionPrompt(prompt)) { - this.scheduleQuestionPromptTurn(turn); + } else if (questionPrompt) { + this.scheduleQuestionPromptTurn(turn, questionPrompt); } else if (largePayload) { this.scheduleLargePayloadTurn(turn, largePayload); } else if (stress) { @@ -712,9 +781,12 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } - private scheduleQuestionPromptTurn(turn: ActiveTurn): void { + private scheduleQuestionPromptTurn( + turn: ActiveTurn, + questionPrompt: MockQuestionPromptRequest, + ): void { turn.timer = setTimeout(() => { - this.emitQuestionPromptTurn(turn); + this.emitQuestionPromptTurn(turn, questionPrompt); }, 0); turn.timer.unref?.(); } @@ -771,7 +843,10 @@ export class MockLoadTestAgentSession implements AgentSession { }); } - private emitQuestionPromptTurn(turn: ActiveTurn): void { + private emitQuestionPromptTurn( + turn: ActiveTurn, + questionPrompt: MockQuestionPromptRequest, + ): void { if (this.activeTurn !== turn) { return; } @@ -790,27 +865,7 @@ export class MockLoadTestAgentSession implements AgentSession { kind: "question", title: "Questions", input: { - questions: [ - { - question: "Which surface should this apply to?", - header: "surface", - options: [{ label: "App" }, { label: "Desktop" }], - multiSelect: false, - }, - { - question: "Which rollout should we use?", - header: "rollout", - options: [{ label: "Immediately" }, { label: "Behind feature flag" }], - multiSelect: false, - }, - { - question: "What success criteria should we use?", - header: "success", - options: [], - multiSelect: false, - placeholder: "Describe success...", - }, - ], + questions: questionPrompt.questions, }, metadata: { source: "mock_questions", From b2cd61a6fe05ef5e9556d12796763e5bbbc823c4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 12:20:24 +0800 Subject: [PATCH 2/2] Keep PR status current after agent merges (#1455) * feat(server): refresh PR state promptly after agent merges via gh CLI When an agent merges a PR with gh pr merge, the app could take up to 2 minutes to show the merged state because: - gh pr merge is a remote GitHub operation that does not touch local git refs, so file watchers never fire - the GitHub self-heal poll runs on a 2-minute slow interval for settled PRs - no refresh is triggered when agent tool calls complete Add a detection path that intercepts completed shell tool calls in the agent manager and signals the workspace git service when a command may have changed external state (gh *, git push, npm publish). The git service then invalidates the GitHub cache and schedules a forced GitHub-inclusive refresh, so the updated PR state arrives within ~500ms. The agent manager does not leak cache semantics; it calls a semantic onWorkspaceStateMayHaveChanged callback and lets the git service decide what that means internally. * refactor(server): share workspace refresh merge logic --- .../src/server/agent/agent-manager.test.ts | 142 +++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 33 ++++ packages/server/src/server/bootstrap.ts | 3 + .../src/server/paseo-worktree-service.test.ts | 1 + .../session.workspace-git-watch.test.ts | 1 + ...on.workspace-resolution-invariants.test.ts | 1 + .../src/server/session.workspaces.test.ts | 7 + .../test-utils/workspace-git-service-stub.ts | 1 + .../server/src/server/websocket-server.ts | 1 + .../server/src/server/wire-compat.test.ts | 1 + .../workspace-git-service.primitive.test.ts | 69 ++++++++- .../src/server/workspace-git-service.test.ts | 2 +- .../src/server/workspace-git-service.ts | 89 +++++++---- 13 files changed, 315 insertions(+), 36 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index e05cb4cd7..dfbee5a47 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -6,7 +6,12 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent-manager.js"; +import { + AgentManager, + commandMayHaveChangedExternalState, + type AgentManagerEvent, + type ManagedAgent, +} from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt } from "./agent-prompt.js"; @@ -5899,3 +5904,138 @@ test("user_message events wrapping a paseo-system envelope are not restored duri expect(userMessages).toHaveLength(1); expect(userMessages[0].text).toBe("real user message"); }); + +test("commandMayHaveChangedExternalState matches remote-state commands", () => { + // GitHub PR operations (remote, no local file changes) + expect(commandMayHaveChangedExternalState("gh pr merge 123")).toBe(true); + expect(commandMayHaveChangedExternalState("gh pr close 123")).toBe(true); + expect(commandMayHaveChangedExternalState("gh pr create")).toBe(true); + expect(commandMayHaveChangedExternalState("gh pr edit 123")).toBe(true); + expect(commandMayHaveChangedExternalState('gh pr comment 123 -b "lgtm"')).toBe(true); + expect(commandMayHaveChangedExternalState("gh pr review 123 -a")).toBe(true); + // Git remote operations (local refs unchanged) + expect(commandMayHaveChangedExternalState("git push origin main")).toBe(true); + expect(commandMayHaveChangedExternalState("git fetch origin")).toBe(true); +}); + +test("commandMayHaveChangedExternalState ignores local or read-only commands", () => { + // Local git mutations — already caught by file watchers on .git/HEAD + expect(commandMayHaveChangedExternalState("git commit -m 'hello'")).toBe(false); + expect(commandMayHaveChangedExternalState("git checkout main")).toBe(false); + expect(commandMayHaveChangedExternalState("git merge feature")).toBe(false); + expect(commandMayHaveChangedExternalState("git rebase main")).toBe(false); + expect(commandMayHaveChangedExternalState("git reset --hard HEAD~1")).toBe(false); + // git pull includes a merge/rebase that changes local refs → watchers catch it + expect(commandMayHaveChangedExternalState("git pull origin main")).toBe(false); + // Read-only gh commands + expect(commandMayHaveChangedExternalState("gh pr view 123")).toBe(false); + expect(commandMayHaveChangedExternalState("gh pr list")).toBe(false); + expect(commandMayHaveChangedExternalState("gh auth status")).toBe(false); + expect(commandMayHaveChangedExternalState("gh repo view")).toBe(false); + // Miscellaneous local commands + expect(commandMayHaveChangedExternalState("git status")).toBe(false); + expect(commandMayHaveChangedExternalState("ls -la")).toBe(false); + expect(commandMayHaveChangedExternalState("cat file.txt")).toBe(false); + expect(commandMayHaveChangedExternalState("npm install")).toBe(false); + expect(commandMayHaveChangedExternalState("npm publish")).toBe(false); +}); + +test("onWorkspaceStateMayHaveChanged is called when a completed shell tool call may have changed external state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const onWorkspaceStateMayHaveChanged = vi.fn(); + + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "tool_call", + callId: "call-1", + name: "bash", + status: "completed", + detail: { type: "shell", command: "gh pr merge 123 --squash" }, + error: null, + }, + ], + }); + + const manager = new AgentManager({ + clients: { codex }, + registry: storage, + logger, + onWorkspaceStateMayHaveChanged, + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + await manager.runAgent(snapshot.id, { text: "merge it" }); + + expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledTimes(1); + expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledWith({ cwd: workdir }); +}); + +test("onWorkspaceStateMayHaveChanged is not called for non-shell tool calls", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const onWorkspaceStateMayHaveChanged = vi.fn(); + + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "tool_call", + callId: "call-1", + name: "read", + status: "completed", + detail: { type: "read", filePath: "/tmp/foo.txt" }, + error: null, + }, + ], + }); + + const manager = new AgentManager({ + clients: { codex }, + registry: storage, + logger, + onWorkspaceStateMayHaveChanged, + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + await manager.runAgent(snapshot.id, { text: "read it" }); + + expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); +}); + +test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const onWorkspaceStateMayHaveChanged = vi.fn(); + + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "tool_call", + callId: "call-1", + name: "bash", + status: "running", + detail: { type: "shell", command: "gh pr merge 123 --squash" }, + error: null, + }, + ], + }); + + const manager = new AgentManager({ + clients: { codex }, + registry: storage, + logger, + onWorkspaceStateMayHaveChanged, + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + await manager.runAgent(snapshot.id, { text: "merge it" }); + + expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ec8c7bd0c..5855af4e0 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -192,6 +192,7 @@ export interface AgentManagerOptions { idFactory?: () => string; registry?: AgentStorage; onAgentAttention?: AgentAttentionCallback; + onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; mcpBaseUrl?: string; @@ -484,6 +485,7 @@ export class AgentManager { private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; + private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void; private logger: Logger; private readonly rescueTimeouts: Required; @@ -492,6 +494,7 @@ export class AgentManager { this.registry = options?.registry; this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; + this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); @@ -3215,6 +3218,19 @@ export class AgentManager { epoch: this.timelineStore.getEpoch(agentId), timestamp: row.timestamp, }); + + if ( + item.type === "tool_call" && + item.status === "completed" && + item.detail?.type === "shell" && + commandMayHaveChangedExternalState(item.detail.command) + ) { + const agent = this.agents.get(agentId); + if (agent) { + this.onWorkspaceStateMayHaveChanged?.({ cwd: agent.cwd }); + } + } + return event; } @@ -3662,3 +3678,20 @@ export class AgentManager { return agent; } } + +export function commandMayHaveChangedExternalState(command: string): boolean { + const normalized = command.toLowerCase(); + // Commands that operate on remote state and do NOT trigger local file + // watchers. Local git mutations (commit, checkout, merge, rebase, reset, + // pull) are already caught by watchers on .git/HEAD and refs/heads/. + return ( + // GitHub PR operations (merge, close, create, edit, comment, review) + /\bgh\s+pr\s+(merge|close|create|edit|comment|review)\b/.test(normalized) || + // Pushes to remote — local refs unchanged, but remote state (PR checks, + // mergeable status) may shift immediately after. + /\bgit\s+push\b/.test(normalized) || + // Fetches update refs/remotes/ which our watchers do not watch, so + // ahead/behind counts can drift stale until the next refresh. + /\bgit\s+fetch\b/.test(normalized) + ); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index b33803051..47932dbc9 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -547,6 +547,9 @@ export async function createPaseoDaemon( providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, appendSystemPrompt: config.appendSystemPrompt, + onWorkspaceStateMayHaveChanged: ({ cwd }) => { + workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); + }, logger, }); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 0ee8bdde9..923f74764 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -597,6 +597,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService { unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, dispose: () => {}, }; } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 4a95deadd..97178d4c5 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -221,6 +221,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index e0785a513..4ce0d17a2 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -147,6 +147,7 @@ function createHarness(input: { unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index b5cd4f560..2012bc8f0 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -521,6 +521,7 @@ function createSessionForWorkspaceTests( unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -631,6 +632,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -1013,6 +1015,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -1373,6 +1376,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -1562,6 +1566,7 @@ test("close_items_request archives stored agents that are not currently loaded", unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -1712,6 +1717,7 @@ test("close_items_request continues after an archive failure", async () => { unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, @@ -2578,6 +2584,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, getMetrics: () => ({ checkoutDiffTargetCount: 0, checkoutDiffSubscriptionCount: 0, diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index 0d4d2ac37..b9a04379a 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -77,6 +77,7 @@ export function createNoopWorkspaceGitService( unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, dispose: () => {}, ...overrides, }; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 923298ece..502d9e2c0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -147,6 +147,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { unsubscribe: () => {}, }), scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, dispose: () => {}, }; } diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 506be11b0..55a8fceb3 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -252,6 +252,7 @@ function createSessionForWireCompatTest(options?: { loopService: {} as SessionOptions["loopService"], checkoutDiffManager: { scheduleRefreshForCwd() {}, + onWorkspaceStateMayHaveChanged() {}, } as unknown as SessionOptions["checkoutDiffManager"], github: { invalidate() {}, diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 10131ee60..7c24b942e 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -509,7 +509,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { for (let index = 0; index < 5; index += 1) { service.scheduleRefreshForCwd(REPO_CWD); } - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(2); @@ -644,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" }); await flushPromises(); service.scheduleRefreshForCwd(REPO_CWD); - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); forcedRefresh.resolve(createCheckoutStatus(REPO_CWD)); @@ -776,7 +776,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { subscription.unsubscribe(); watchCallbacks[0]?.(); - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback); @@ -1728,4 +1728,67 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { } }, ); + + test("onWorkspaceStateMayHaveChanged invalidates github cache and schedules a forced github-inclusive refresh", async () => { + const github = createGitHubServiceStub(); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + github, + }); + + await service.getSnapshot(REPO_CWD); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.onWorkspaceStateMayHaveChanged(REPO_CWD); + + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + + expect(github.invalidate).toHaveBeenCalledWith({ cwd: REPO_CWD }); + expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + + service.dispose(); + }); + + test("onWorkspaceStateMayHaveChanged preserves includeGitHub when a file watcher fires within the debounce window", async () => { + const github = createGitHubServiceStub(); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); + const service = createService({ + getCheckoutStatus, + getPullRequestStatus, + github, + }); + + await service.getSnapshot(REPO_CWD); + expect(getPullRequestStatus).toHaveBeenCalledTimes(1); + + service.onWorkspaceStateMayHaveChanged(REPO_CWD); + // File watcher fires 200ms later (before debounce expires) + await vi.advanceTimersByTimeAsync(200); + service.scheduleRefreshForCwd(REPO_CWD); + + // Advance past the debounce + await vi.advanceTimersByTimeAsync(1_000); + await flushPromises(); + + // Should still refresh GitHub because the first signal asked for it + expect(getPullRequestStatus).toHaveBeenCalledTimes(2); + service.dispose(); + }); + + test("onWorkspaceStateMayHaveChanged is a no-op for unknown cwds", () => { + const github = createGitHubServiceStub(); + const service = createService({ github }); + + service.onWorkspaceStateMayHaveChanged("/unknown/cwd"); + + expect(github.invalidate).not.toHaveBeenCalled(); + service.dispose(); + }); }); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index eb07fc11f..c44ee6e92 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -878,7 +878,7 @@ describe("WorkspaceGitServiceImpl", () => { expect(repoRootWatch).toBeDefined(); repoRootWatch?.callback(); - await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(1_000); await flushPromises(); expect(getCheckoutShortstat).toHaveBeenLastCalledWith( diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index def8f4320..f86b0719c 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -40,7 +40,7 @@ import { } from "./workspace-git-metadata.js"; import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js"; -const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; +const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; @@ -158,6 +158,7 @@ export interface WorkspaceGitService { onChange: () => void, ): Promise<{ repoRoot: string | null; unsubscribe: () => void }>; scheduleRefreshForCwd(cwd: string): void; + onWorkspaceStateMayHaveChanged(cwd: string): void; dispose(): void; } @@ -217,11 +218,10 @@ interface WorkspaceGitRefreshRequest { notify: boolean; } -interface QueuedWorkspaceGitRefresh { - force: boolean; - includeGitHub: boolean; - reason: string; - notify: boolean; +interface ScheduledWorkspaceGitRefreshOptions { + force?: boolean; + includeGitHub?: boolean; + reason?: string; } type WorkspaceGitRefreshState = @@ -233,7 +233,7 @@ type WorkspaceGitRefreshState = promise: Promise; force: boolean; includeGitHub: boolean; - queued: QueuedWorkspaceGitRefresh | null; + queued: WorkspaceGitRefreshRequest | null; }; interface WorkspaceGitServiceDependencies { @@ -268,6 +268,7 @@ interface WorkspaceGitTarget { listeners: Set; watchers: FSWatcher[]; debounceTimer: NodeJS.Timeout | null; + pendingDebounceRequest: WorkspaceGitRefreshRequest | null; selfHealTimer: NodeJS.Timeout | null; githubPollSubscription: { unsubscribe: () => void } | null; githubPollKey: string | null; @@ -686,6 +687,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } + onWorkspaceStateMayHaveChanged(cwd: string): void { + const normalizedCwd = normalizeWorkspaceId(cwd); + const target = this.workspaceTargets.get(normalizedCwd); + if (!target || target.closed) { + return; + } + this.deps.github.invalidate({ cwd: normalizedCwd }); + this.scheduleWorkspaceRefresh(target, { + force: true, + includeGitHub: true, + reason: "external-state-change", + }); + } + dispose(): void { for (const target of this.workspaceTargets.values()) { this.closeWorkspaceTarget(target); @@ -799,6 +814,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { listeners: new Set(), watchers: [], debounceTimer: null, + pendingDebounceRequest: null, selfHealTimer: null, githubPollSubscription: null, githubPollKey: null, @@ -1100,7 +1116,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private scheduleWorkspaceRefresh( targetOrCwd: WorkspaceGitTarget | string, - options?: { force?: boolean; reason?: string }, + options?: ScheduledWorkspaceGitRefreshOptions, ): void { const target = typeof targetOrCwd === "string" @@ -1110,6 +1126,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } + const request = this.buildScheduledRefreshRequest(options); + target.pendingDebounceRequest = this.mergeRefreshRequests( + target.pendingDebounceRequest, + request, + ); + if (target.debounceTimer) { clearTimeout(target.debounceTimer); } @@ -1119,12 +1141,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.debounceTimer = null; - void this.refreshWorkspaceTarget(target, { - force: options?.force === true, - includeGitHub: false, - reason: options?.reason ?? "watch", - notify: true, - }); + const merged = target.pendingDebounceRequest; + target.pendingDebounceRequest = null; + if (merged) { + void this.refreshWorkspaceTarget(target, merged); + } }, WORKSPACE_GIT_WATCH_DEBOUNCE_MS); } @@ -1460,7 +1481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const needsGitHubRefresh = request.force && request.includeGitHub && !target.refreshState.includeGitHub; if (needsForcedRefresh || needsGitHubRefresh) { - target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request); + target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request); } return target.refreshState.promise; } @@ -1532,27 +1553,33 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS; } - private mergeQueuedRefresh( - queued: QueuedWorkspaceGitRefresh | null, + private buildScheduledRefreshRequest( + options: ScheduledWorkspaceGitRefreshOptions | undefined, + ): WorkspaceGitRefreshRequest { + return { + force: options?.force === true, + includeGitHub: options?.includeGitHub ?? false, + reason: options?.reason ?? "watch", + notify: true, + }; + } + + private mergeRefreshRequests( + pending: WorkspaceGitRefreshRequest | null, request: WorkspaceGitRefreshRequest, - ): QueuedWorkspaceGitRefresh { - if (!queued) { - return { - force: request.force, - includeGitHub: request.includeGitHub, - reason: request.reason, - notify: request.notify, - }; + ): WorkspaceGitRefreshRequest { + if (!pending) { + return request; } - const force = queued.force || request.force; - const upgradesForce = request.force && !queued.force; - const upgradesGitHub = request.includeGitHub && !queued.includeGitHub; + const force = pending.force || request.force; + const upgradesForce = request.force && !pending.force; + const upgradesGitHub = request.includeGitHub && !pending.includeGitHub; return { force, - includeGitHub: queued.includeGitHub || request.includeGitHub, - reason: upgradesForce || upgradesGitHub ? request.reason : queued.reason, - notify: queued.notify || request.notify, + includeGitHub: pending.includeGitHub || request.includeGitHub, + reason: upgradesForce || upgradesGitHub ? request.reason : pending.reason, + notify: pending.notify || request.notify, }; }