Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -44,6 +44,18 @@ export async function openQuestion(
|
|||||||
.click();
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionNavigationEnabled(
|
||||||
|
page: Page,
|
||||||
|
input: { index: number; total: number },
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||||
|
).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fillQuestionAnswer(
|
export async function fillQuestionAnswer(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { question: string; answer: string },
|
input: { question: string; answer: string },
|
||||||
@@ -59,3 +71,32 @@ export async function submitQuestionAnswers(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("question-form-primary-action").click();
|
await page.getByTestId("question-form-primary-action").click();
|
||||||
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionPrimaryActionEnabled(page: Page, label: string): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
|
||||||
|
).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionPrimaryActionDisabled(
|
||||||
|
page: Page,
|
||||||
|
label: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
|
||||||
|
).toBeDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectQuestionDismissEnabled(page: Page): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("question-form-card").first().getByRole("button", { name: "Dismiss" }),
|
||||||
|
).toBeEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function continueToNextQuestion(page: Page): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByTestId("question-form-card")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "Next" })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { test } from "./fixtures";
|
|||||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import {
|
import {
|
||||||
chooseQuestionOption,
|
chooseQuestionOption,
|
||||||
|
continueToNextQuestion,
|
||||||
expectCurrentQuestion,
|
expectCurrentQuestion,
|
||||||
|
expectQuestionDismissEnabled,
|
||||||
expectQuestionHidden,
|
expectQuestionHidden,
|
||||||
|
expectQuestionNavigationEnabled,
|
||||||
expectQuestionOptionSelected,
|
expectQuestionOptionSelected,
|
||||||
|
expectQuestionPrimaryActionDisabled,
|
||||||
|
expectQuestionPrimaryActionEnabled,
|
||||||
fillQuestionAnswer,
|
fillQuestionAnswer,
|
||||||
openQuestion,
|
openQuestion,
|
||||||
submitQuestionAnswers,
|
submitQuestionAnswers,
|
||||||
@@ -15,6 +20,8 @@ const TOTAL_QUESTIONS = 3;
|
|||||||
const SURFACE_QUESTION = "Which surface should this apply to?";
|
const SURFACE_QUESTION = "Which surface should this apply to?";
|
||||||
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||||
const SUCCESS_QUESTION = "What success criteria 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.describe("Question prompt pagination", () => {
|
||||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||||
@@ -70,4 +77,51 @@ test.describe("Question prompt pagination", () => {
|
|||||||
await session.cleanup();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { isWeb } from "@/constants/platform";
|
|||||||
import {
|
import {
|
||||||
areQuestionsAnswered,
|
areQuestionsAnswered,
|
||||||
buildQuestionFormAnswers,
|
buildQuestionFormAnswers,
|
||||||
|
isQuestionAnswered,
|
||||||
parseQuestionFormQuestions,
|
parseQuestionFormQuestions,
|
||||||
questionShowsTextInput,
|
questionShowsTextInput,
|
||||||
resolveDismissLabel,
|
resolveDismissLabel,
|
||||||
@@ -296,6 +297,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
? Math.min(activeQuestionIndex, questions.length - 1)
|
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||||
: 0;
|
: 0;
|
||||||
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||||
|
const activeQuestionAnswered = activeQuestion
|
||||||
|
? isQuestionAnswered(activeQuestion, resolvedActiveQuestionIndex, selections, otherTexts)
|
||||||
|
: false;
|
||||||
|
const isLastQuestion = questions ? resolvedActiveQuestionIndex === questions.length - 1 : true;
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!questions || !allAnswered || isResponding) return;
|
if (!questions || !allAnswered || isResponding) return;
|
||||||
@@ -340,6 +345,15 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
setActiveQuestionIndex(index);
|
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(
|
const dismissButtonStyle = useCallback(
|
||||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
@@ -352,18 +366,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
|
[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(
|
const submitButtonStyle = useCallback(
|
||||||
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.actionButton,
|
styles.actionButton,
|
||||||
{
|
{
|
||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.accent,
|
||||||
borderColor: 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(
|
const containerStyle = useMemo(
|
||||||
@@ -458,7 +473,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||||
isResponding={isResponding}
|
isResponding={isResponding}
|
||||||
onChange={setOtherText}
|
onChange={setOtherText}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handlePrimaryAction}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -485,10 +500,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={submitButtonStyle}
|
style={submitButtonStyle}
|
||||||
onPress={handleSubmit}
|
onPress={handlePrimaryAction}
|
||||||
disabled={submitDisabled}
|
disabled={primaryDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Submit"
|
accessibilityLabel={primaryActionLabel}
|
||||||
testID="question-form-primary-action"
|
testID="question-form-primary-action"
|
||||||
>
|
>
|
||||||
{respondingAction === "submit" ? (
|
{respondingAction === "submit" ? (
|
||||||
@@ -496,7 +511,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.actionContent}>
|
<View style={styles.actionContent}>
|
||||||
<Check size={14} color={submitActionTextColor} />
|
<Check size={14} color={submitActionTextColor} />
|
||||||
<Text style={submitActionTextStyle}>Submit</Text>
|
<Text style={submitActionTextStyle}>{primaryActionLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { tmpdir } from "node:os";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
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 { AgentStorage } from "./agent-storage.js";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
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).toHaveLength(1);
|
||||||
expect(userMessages[0].text).toBe("real user message");
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ export interface AgentManagerOptions {
|
|||||||
idFactory?: () => string;
|
idFactory?: () => string;
|
||||||
registry?: AgentStorage;
|
registry?: AgentStorage;
|
||||||
onAgentAttention?: AgentAttentionCallback;
|
onAgentAttention?: AgentAttentionCallback;
|
||||||
|
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||||
durableTimelineStore?: AgentTimelineStore;
|
durableTimelineStore?: AgentTimelineStore;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
mcpBaseUrl?: string;
|
mcpBaseUrl?: string;
|
||||||
@@ -484,6 +485,7 @@ export class AgentManager {
|
|||||||
private appendSystemPrompt: string;
|
private appendSystemPrompt: string;
|
||||||
private onAgentAttention?: AgentAttentionCallback;
|
private onAgentAttention?: AgentAttentionCallback;
|
||||||
private onAgentArchived?: AgentArchivedCallback;
|
private onAgentArchived?: AgentArchivedCallback;
|
||||||
|
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||||
|
|
||||||
@@ -492,6 +494,7 @@ export class AgentManager {
|
|||||||
this.registry = options?.registry;
|
this.registry = options?.registry;
|
||||||
this.durableTimelineStore = options?.durableTimelineStore;
|
this.durableTimelineStore = options?.durableTimelineStore;
|
||||||
this.onAgentAttention = options?.onAgentAttention;
|
this.onAgentAttention = options?.onAgentAttention;
|
||||||
|
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
|
||||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||||
@@ -3215,6 +3218,19 @@ export class AgentManager {
|
|||||||
epoch: this.timelineStore.getEpoch(agentId),
|
epoch: this.timelineStore.getEpoch(agentId),
|
||||||
timestamp: row.timestamp,
|
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;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3662,3 +3678,20 @@ export class AgentManager {
|
|||||||
return agent;
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ import {
|
|||||||
MockLoadTestAgentClient,
|
MockLoadTestAgentClient,
|
||||||
} from "./mock-load-test-agent.js";
|
} from "./mock-load-test-agent.js";
|
||||||
|
|
||||||
|
type PermissionRequestedEvent = Extract<AgentStreamEvent, { type: "permission_requested" }>;
|
||||||
|
|
||||||
|
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", () => {
|
describe("MockLoadTestAgentClient", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
@@ -131,6 +145,60 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
expect(events).toHaveLength(eventCountAfterInterrupt);
|
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 () => {
|
test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-"));
|
||||||
|
|||||||
@@ -127,12 +127,80 @@ interface AgentStreamStressRequest {
|
|||||||
coalesced: boolean;
|
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 {
|
function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean {
|
||||||
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
|
return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt));
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean {
|
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||||
return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt));
|
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): {
|
function resolveModelProfile(modelId: string | null | undefined): {
|
||||||
@@ -536,10 +604,11 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
|
|
||||||
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||||
const stress = parseAgentStreamStressPrompt(prompt);
|
const stress = parseAgentStreamStressPrompt(prompt);
|
||||||
|
const questionPrompt = parseMockQuestionPrompt(prompt);
|
||||||
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
if (shouldEmitPlanApprovalPrompt(prompt)) {
|
||||||
this.schedulePlanApprovalTurn(turn);
|
this.schedulePlanApprovalTurn(turn);
|
||||||
} else if (shouldEmitQuestionPrompt(prompt)) {
|
} else if (questionPrompt) {
|
||||||
this.scheduleQuestionPromptTurn(turn);
|
this.scheduleQuestionPromptTurn(turn, questionPrompt);
|
||||||
} else if (largePayload) {
|
} else if (largePayload) {
|
||||||
this.scheduleLargePayloadTurn(turn, largePayload);
|
this.scheduleLargePayloadTurn(turn, largePayload);
|
||||||
} else if (stress) {
|
} else if (stress) {
|
||||||
@@ -712,9 +781,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turn.timer.unref?.();
|
turn.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleQuestionPromptTurn(turn: ActiveTurn): void {
|
private scheduleQuestionPromptTurn(
|
||||||
|
turn: ActiveTurn,
|
||||||
|
questionPrompt: MockQuestionPromptRequest,
|
||||||
|
): void {
|
||||||
turn.timer = setTimeout(() => {
|
turn.timer = setTimeout(() => {
|
||||||
this.emitQuestionPromptTurn(turn);
|
this.emitQuestionPromptTurn(turn, questionPrompt);
|
||||||
}, 0);
|
}, 0);
|
||||||
turn.timer.unref?.();
|
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) {
|
if (this.activeTurn !== turn) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -790,27 +865,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
kind: "question",
|
kind: "question",
|
||||||
title: "Questions",
|
title: "Questions",
|
||||||
input: {
|
input: {
|
||||||
questions: [
|
questions: questionPrompt.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...",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
source: "mock_questions",
|
source: "mock_questions",
|
||||||
|
|||||||
@@ -547,6 +547,9 @@ export async function createPaseoDaemon(
|
|||||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||||
registry: agentStorage,
|
registry: agentStorage,
|
||||||
appendSystemPrompt: config.appendSystemPrompt,
|
appendSystemPrompt: config.appendSystemPrompt,
|
||||||
|
onWorkspaceStateMayHaveChanged: ({ cwd }) => {
|
||||||
|
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
|
||||||
|
},
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function createHarness(input: {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ function createSessionForWorkspaceTests(
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -631,6 +632,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -1013,6 +1015,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -1373,6 +1376,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -1562,6 +1566,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -1712,6 +1717,7 @@ test("close_items_request continues after an archive failure", async () => {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
@@ -2578,6 +2584,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
getMetrics: () => ({
|
getMetrics: () => ({
|
||||||
checkoutDiffTargetCount: 0,
|
checkoutDiffTargetCount: 0,
|
||||||
checkoutDiffSubscriptionCount: 0,
|
checkoutDiffSubscriptionCount: 0,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function createNoopWorkspaceGitService(
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
|||||||
unsubscribe: () => {},
|
unsubscribe: () => {},
|
||||||
}),
|
}),
|
||||||
scheduleRefreshForCwd: () => {},
|
scheduleRefreshForCwd: () => {},
|
||||||
|
onWorkspaceStateMayHaveChanged: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ function createSessionForWireCompatTest(options?: {
|
|||||||
loopService: {} as SessionOptions["loopService"],
|
loopService: {} as SessionOptions["loopService"],
|
||||||
checkoutDiffManager: {
|
checkoutDiffManager: {
|
||||||
scheduleRefreshForCwd() {},
|
scheduleRefreshForCwd() {},
|
||||||
|
onWorkspaceStateMayHaveChanged() {},
|
||||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||||
github: {
|
github: {
|
||||||
invalidate() {},
|
invalidate() {},
|
||||||
|
|||||||
@@ -509,7 +509,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
service.scheduleRefreshForCwd(REPO_CWD);
|
service.scheduleRefreshForCwd(REPO_CWD);
|
||||||
}
|
}
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
@@ -644,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
service.scheduleRefreshForCwd(REPO_CWD);
|
service.scheduleRefreshForCwd(REPO_CWD);
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
@@ -776,7 +776,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
watchCallbacks[0]?.();
|
watchCallbacks[0]?.();
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -878,7 +878,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
expect(repoRootWatch).toBeDefined();
|
expect(repoRootWatch).toBeDefined();
|
||||||
|
|
||||||
repoRootWatch?.callback();
|
repoRootWatch?.callback();
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
} from "./workspace-git-metadata.js";
|
} from "./workspace-git-metadata.js";
|
||||||
import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.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;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||||
@@ -158,6 +158,7 @@ export interface WorkspaceGitService {
|
|||||||
onChange: () => void,
|
onChange: () => void,
|
||||||
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
|
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
|
||||||
scheduleRefreshForCwd(cwd: string): void;
|
scheduleRefreshForCwd(cwd: string): void;
|
||||||
|
onWorkspaceStateMayHaveChanged(cwd: string): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,11 +218,10 @@ interface WorkspaceGitRefreshRequest {
|
|||||||
notify: boolean;
|
notify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueuedWorkspaceGitRefresh {
|
interface ScheduledWorkspaceGitRefreshOptions {
|
||||||
force: boolean;
|
force?: boolean;
|
||||||
includeGitHub: boolean;
|
includeGitHub?: boolean;
|
||||||
reason: string;
|
reason?: string;
|
||||||
notify: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceGitRefreshState =
|
type WorkspaceGitRefreshState =
|
||||||
@@ -233,7 +233,7 @@ type WorkspaceGitRefreshState =
|
|||||||
promise: Promise<WorkspaceGitRuntimeSnapshot>;
|
promise: Promise<WorkspaceGitRuntimeSnapshot>;
|
||||||
force: boolean;
|
force: boolean;
|
||||||
includeGitHub: boolean;
|
includeGitHub: boolean;
|
||||||
queued: QueuedWorkspaceGitRefresh | null;
|
queued: WorkspaceGitRefreshRequest | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WorkspaceGitServiceDependencies {
|
interface WorkspaceGitServiceDependencies {
|
||||||
@@ -268,6 +268,7 @@ interface WorkspaceGitTarget {
|
|||||||
listeners: Set<WorkspaceGitListener>;
|
listeners: Set<WorkspaceGitListener>;
|
||||||
watchers: FSWatcher[];
|
watchers: FSWatcher[];
|
||||||
debounceTimer: NodeJS.Timeout | null;
|
debounceTimer: NodeJS.Timeout | null;
|
||||||
|
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
|
||||||
selfHealTimer: NodeJS.Timeout | null;
|
selfHealTimer: NodeJS.Timeout | null;
|
||||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||||
githubPollKey: string | 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 {
|
dispose(): void {
|
||||||
for (const target of this.workspaceTargets.values()) {
|
for (const target of this.workspaceTargets.values()) {
|
||||||
this.closeWorkspaceTarget(target);
|
this.closeWorkspaceTarget(target);
|
||||||
@@ -799,6 +814,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
watchers: [],
|
watchers: [],
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
|
pendingDebounceRequest: null,
|
||||||
selfHealTimer: null,
|
selfHealTimer: null,
|
||||||
githubPollSubscription: null,
|
githubPollSubscription: null,
|
||||||
githubPollKey: null,
|
githubPollKey: null,
|
||||||
@@ -1100,7 +1116,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private scheduleWorkspaceRefresh(
|
private scheduleWorkspaceRefresh(
|
||||||
targetOrCwd: WorkspaceGitTarget | string,
|
targetOrCwd: WorkspaceGitTarget | string,
|
||||||
options?: { force?: boolean; reason?: string },
|
options?: ScheduledWorkspaceGitRefreshOptions,
|
||||||
): void {
|
): void {
|
||||||
const target =
|
const target =
|
||||||
typeof targetOrCwd === "string"
|
typeof targetOrCwd === "string"
|
||||||
@@ -1110,6 +1126,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const request = this.buildScheduledRefreshRequest(options);
|
||||||
|
target.pendingDebounceRequest = this.mergeRefreshRequests(
|
||||||
|
target.pendingDebounceRequest,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
}
|
}
|
||||||
@@ -1119,12 +1141,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
void this.refreshWorkspaceTarget(target, {
|
const merged = target.pendingDebounceRequest;
|
||||||
force: options?.force === true,
|
target.pendingDebounceRequest = null;
|
||||||
includeGitHub: false,
|
if (merged) {
|
||||||
reason: options?.reason ?? "watch",
|
void this.refreshWorkspaceTarget(target, merged);
|
||||||
notify: true,
|
}
|
||||||
});
|
|
||||||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,7 +1481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const needsGitHubRefresh =
|
const needsGitHubRefresh =
|
||||||
request.force && request.includeGitHub && !target.refreshState.includeGitHub;
|
request.force && request.includeGitHub && !target.refreshState.includeGitHub;
|
||||||
if (needsForcedRefresh || needsGitHubRefresh) {
|
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;
|
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;
|
return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeQueuedRefresh(
|
private buildScheduledRefreshRequest(
|
||||||
queued: QueuedWorkspaceGitRefresh | null,
|
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,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): QueuedWorkspaceGitRefresh {
|
): WorkspaceGitRefreshRequest {
|
||||||
if (!queued) {
|
if (!pending) {
|
||||||
return {
|
return request;
|
||||||
force: request.force,
|
|
||||||
includeGitHub: request.includeGitHub,
|
|
||||||
reason: request.reason,
|
|
||||||
notify: request.notify,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const force = queued.force || request.force;
|
const force = pending.force || request.force;
|
||||||
const upgradesForce = request.force && !queued.force;
|
const upgradesForce = request.force && !pending.force;
|
||||||
const upgradesGitHub = request.includeGitHub && !queued.includeGitHub;
|
const upgradesGitHub = request.includeGitHub && !pending.includeGitHub;
|
||||||
return {
|
return {
|
||||||
force,
|
force,
|
||||||
includeGitHub: queued.includeGitHub || request.includeGitHub,
|
includeGitHub: pending.includeGitHub || request.includeGitHub,
|
||||||
reason: upgradesForce || upgradesGitHub ? request.reason : queued.reason,
|
reason: upgradesForce || upgradesGitHub ? request.reason : pending.reason,
|
||||||
notify: queued.notify || request.notify,
|
notify: pending.notify || request.notify,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user