mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757bf81cb9 | ||
|
|
c56c4bb390 | ||
|
|
92504cd525 | ||
|
|
726067b437 | ||
|
|
ec4e81ce0f | ||
|
|
bc385b5930 | ||
|
|
ec5ab3fc4d | ||
|
|
69510ac72f |
@@ -80,8 +80,11 @@ function QuestionOptionRow({
|
||||
);
|
||||
|
||||
const optionLabelStyle = useMemo(
|
||||
() => [styles.optionLabel, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
() => [
|
||||
styles.optionLabel,
|
||||
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const optionDescriptionStyle = useMemo(
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
@@ -96,11 +99,11 @@ function QuestionOptionRow({
|
||||
styles.selectionControl,
|
||||
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
|
||||
{
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted,
|
||||
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
|
||||
},
|
||||
],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
|
||||
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted],
|
||||
);
|
||||
const radioDotStyle = useMemo(
|
||||
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
|
||||
@@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
@@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
@@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
@@ -129,7 +128,10 @@ import {
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "./workspace/terminals/state";
|
||||
import { createWorkspaceAgentInBackground } from "./new-workspace/background-handoff";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
@@ -762,7 +764,7 @@ function normalizeBranchDetails(
|
||||
type SubmitOutcome = "navigated" | "background";
|
||||
|
||||
interface SubmitDraftInput {
|
||||
draftVersionAtSubmit: number | undefined;
|
||||
clearConsumedDraft: () => void;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
@@ -943,7 +945,7 @@ function buildComposerInitialValues(input: {
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
@@ -972,7 +974,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
|
||||
composerState,
|
||||
});
|
||||
return await submitWorkspaceDraft({
|
||||
draftVersionAtSubmit,
|
||||
clearConsumedDraft,
|
||||
serverId,
|
||||
clearDraft,
|
||||
draftKey: input.draftKey,
|
||||
@@ -1082,11 +1084,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
|
||||
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
|
||||
if (!input.isStillOnCreateScreen()) {
|
||||
await createWorkspaceAgentInBackground({
|
||||
draftVersionAtSubmit: input.draftVersionAtSubmit,
|
||||
draftId,
|
||||
draftKey: input.draftKey,
|
||||
clearDraft,
|
||||
draftContextScopeKey: input.draftContextScopeKey,
|
||||
clearConsumedDraft: input.clearConsumedDraft,
|
||||
createAgent: () =>
|
||||
requestWorkspaceDraftAgent(input.resolveClient(), {
|
||||
workspaceId,
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { createWorkspaceAgentInBackground } from "./background-handoff";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./background-handoff";
|
||||
|
||||
const DRAFT_ID = "draft_1";
|
||||
const DRAFT_KEY = "new-workspace";
|
||||
@@ -19,21 +22,20 @@ function draftText(): string | undefined {
|
||||
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
|
||||
}
|
||||
|
||||
function run(
|
||||
createAgent: () => Promise<unknown>,
|
||||
draftVersionAtSubmit = useDraftStore.getState().drafts[DRAFT_KEY]?.version,
|
||||
) {
|
||||
return createWorkspaceAgentInBackground({
|
||||
function captureCleanup() {
|
||||
return captureWorkspaceDraftCleanup({
|
||||
draftId: DRAFT_ID,
|
||||
draftKey: DRAFT_KEY,
|
||||
draftVersionAtSubmit,
|
||||
clearDraft: (lifecycle) =>
|
||||
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftContextScopeKey: SCOPE_KEY,
|
||||
createAgent,
|
||||
});
|
||||
}
|
||||
|
||||
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
|
||||
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
@@ -42,6 +44,41 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentInBackground", () => {
|
||||
it("preserves context added to a newer draft without editing its text", async () => {
|
||||
await run(async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/new-repo",
|
||||
provider: "codex",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
|
||||
"/work/new-repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
|
||||
let calls = 0;
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
@@ -66,6 +103,28 @@ describe("createWorkspaceAgentInBackground", () => {
|
||||
expect(draftText()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves context changed while workspace creation was pending", async () => {
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
|
||||
await run(async () => undefined);
|
||||
|
||||
@@ -93,9 +152,25 @@ describe("createWorkspaceAgentInBackground", () => {
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
const submittedVersion = useDraftStore.getState().drafts[DRAFT_KEY]?.version;
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, submittedVersion);
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,37 @@ import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachment
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
export interface CreateWorkspaceAgentInBackgroundInput {
|
||||
draftId: string;
|
||||
interface WorkspaceDraftCleanupInput {
|
||||
draftId?: string;
|
||||
draftKey: string;
|
||||
draftVersionAtSubmit: number | undefined;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftContextScopeKey: string | null;
|
||||
createAgent: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Capture ownership before workspace creation starts, not when its response arrives. */
|
||||
export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void {
|
||||
const draftId = input.draftId?.trim() ?? "";
|
||||
const scopeKey = input.draftContextScopeKey ?? "";
|
||||
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId];
|
||||
const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
|
||||
|
||||
return () => {
|
||||
// These stores update independently. A context-only edit does not bump the text version.
|
||||
// Any newer draft state owns the whole draft, including its unchanged context.
|
||||
if (
|
||||
useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion ||
|
||||
useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup ||
|
||||
useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments
|
||||
) {
|
||||
return;
|
||||
}
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
if (scopeKey) {
|
||||
useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey });
|
||||
}
|
||||
input.clearDraft("sent");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,22 +45,10 @@ export interface CreateWorkspaceAgentInBackgroundInput {
|
||||
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
|
||||
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
|
||||
*/
|
||||
export async function createWorkspaceAgentInBackground(
|
||||
input: CreateWorkspaceAgentInBackgroundInput,
|
||||
): Promise<void> {
|
||||
export async function createWorkspaceAgentInBackground(input: {
|
||||
clearConsumedDraft: () => void;
|
||||
createAgent: () => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
await input.createAgent();
|
||||
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId: input.draftId });
|
||||
if (input.draftContextScopeKey) {
|
||||
useWorkspaceAttachmentsStore
|
||||
.getState()
|
||||
.clearWorkspaceAttachments({ scopeKey: input.draftContextScopeKey });
|
||||
}
|
||||
// Only on success — a failed creation has no pending-store handoff holding the prompt, so
|
||||
// clearing would destroy the text needed to retry. And only if nothing else has written to the
|
||||
// draft since: the New workspace draft key is shared, so the user may have reopened the screen
|
||||
// and started typing something new while this was in flight.
|
||||
if (useDraftStore.getState().drafts[input.draftKey]?.version === input.draftVersionAtSubmit) {
|
||||
input.clearDraft("sent");
|
||||
}
|
||||
input.clearConsumedDraft();
|
||||
}
|
||||
|
||||
@@ -2885,6 +2885,9 @@ export class AgentManager {
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (agent.inFlightPermissionResponses.has(requestId)) {
|
||||
throw new Error("A response to this permission request is already being submitted");
|
||||
}
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
try {
|
||||
@@ -3079,8 +3082,11 @@ export class AgentManager {
|
||||
epoch: this.timelineStore.getEpoch(agentId),
|
||||
});
|
||||
}
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
// Rewind stages provider events under the run lock; publish its final state directly.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.refreshSessionState(agent, { emit: false });
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.complete",
|
||||
@@ -4231,14 +4237,18 @@ export class AgentManager {
|
||||
|
||||
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
||||
const previousSessionId = agent.persistence?.sessionId ?? null;
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
if (agent.persistence?.sessionId !== previousSessionId) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private async onStreamTimelineEvent(params: {
|
||||
@@ -4443,6 +4453,7 @@ export class AgentManager {
|
||||
): void {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
@@ -4457,6 +4468,7 @@ export class AgentManager {
|
||||
}): void {
|
||||
const { agent, event, options, flags } = params;
|
||||
agent.pendingPermissions.delete(event.requestId);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
||||
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
||||
flags.shouldDispatchEvent = false;
|
||||
|
||||
@@ -24,6 +24,12 @@ interface TestClaudeSession {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function isLoadingCompactionEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading"
|
||||
);
|
||||
}
|
||||
|
||||
function isPermissionResolvedEvent(
|
||||
event: AgentStreamEvent,
|
||||
): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> {
|
||||
@@ -1981,6 +1987,15 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createCompactingStatus(): Record<string, unknown> {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: "compacting",
|
||||
session_id: "session-1",
|
||||
};
|
||||
}
|
||||
|
||||
test("emits turn_started before the submitted user message", async () => {
|
||||
const session = await createSessionForTurns([[]]);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -2857,6 +2872,110 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated compacting statuses open a single compaction marker", async () => {
|
||||
const session = await createSessionForTurns([
|
||||
[
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactBoundary(),
|
||||
createCompactingStatus(),
|
||||
createCompactingStatus(),
|
||||
createCompactBoundary(),
|
||||
createSuccessResult(),
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
const events = await collectStreamEvents(session, "compact twice");
|
||||
const compactions = events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [],
|
||||
);
|
||||
expect(compactions).toEqual(["loading", "completed", "loading", "completed"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => {
|
||||
// The first turn starts compacting and then ends without ever reaching a
|
||||
// compact_boundary, so the marker it opened is never resolved.
|
||||
const session = await createSessionForTurns([
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const abandonedTurn = await collectStreamEvents(session, "abandoned compaction");
|
||||
expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("an interrupted compaction does not suppress the next compaction marker", async () => {
|
||||
// The first turn starts compacting and is then interrupted, so it never reaches a
|
||||
// compact_boundary and the marker it opened is never resolved.
|
||||
const session = await createSessionForTurns([
|
||||
[createCompactingStatus()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const interruptedTurn: AgentStreamEvent[] = [];
|
||||
const streaming = (async () => {
|
||||
for await (const event of streamSession(session, "interrupted compaction")) {
|
||||
interruptedTurn.push(event);
|
||||
}
|
||||
})();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
});
|
||||
await session.interrupt();
|
||||
await streaming;
|
||||
|
||||
expect(interruptedTurn).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled", provider: "claude" }),
|
||||
);
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => {
|
||||
// Trailing output after the foreground result opens an autonomous turn, which starts
|
||||
// compacting and is then ended by the next foreground turn, never reaching a boundary.
|
||||
const session = await createSessionForTurns([
|
||||
[createSuccessResult(), createMessageStartEvent(), createCompactingStatus()],
|
||||
[createCompactingStatus(), createSuccessResult()],
|
||||
]);
|
||||
|
||||
try {
|
||||
const observed: AgentStreamEvent[] = [];
|
||||
const unsubscribe = session.subscribe((event) => {
|
||||
observed.push(event);
|
||||
});
|
||||
|
||||
await collectStreamEvents(session, "foreground turn");
|
||||
await vi.waitFor(() => {
|
||||
expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
const nextTurn = await collectStreamEvents(session, "next compaction");
|
||||
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
|
||||
@@ -2095,6 +2095,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private lastOptionsModel: string | null = null;
|
||||
private lastRuntimeModel: string | null = null;
|
||||
private compacting = false;
|
||||
private compactionMarkerOpen = false;
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
private pendingInterruptAbort = false;
|
||||
@@ -3572,6 +3573,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundInput = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
}
|
||||
|
||||
@@ -3583,6 +3585,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (terminalSeen) {
|
||||
this.compactionMarkerOpen = false;
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeForegroundQuery = null;
|
||||
@@ -3624,6 +3627,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundQuery = null;
|
||||
this.activeForegroundInput = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.compactionMarkerOpen = false;
|
||||
this.syncTurnState("autonomous turn completed");
|
||||
}
|
||||
|
||||
@@ -4260,15 +4264,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const status = toObjectRecord(message)?.status;
|
||||
if (status === "compacting") {
|
||||
this.compacting = true;
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
provider: "claude",
|
||||
});
|
||||
// Claude Code repeats this status every 30 seconds until the compaction
|
||||
// finishes. Each repeat used to open its own marker, and the app only ever
|
||||
// resolves one of them, so the rest stayed on "Compacting..." forever.
|
||||
if (!this.compactionMarkerOpen) {
|
||||
this.compactionMarkerOpen = true;
|
||||
events.push({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
provider: "claude",
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.subtype === "compact_boundary") {
|
||||
this.compactionMarkerOpen = false;
|
||||
const compactMetadata = readCompactionMetadata(message);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
|
||||
@@ -52,6 +52,7 @@ import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||
import {
|
||||
mapCodexToolCallEnvelope,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
@@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem(
|
||||
);
|
||||
}
|
||||
|
||||
function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem {
|
||||
const question = codexAsyncQuestionToTimeline(item);
|
||||
if (question) return question;
|
||||
const messageId = nonEmptyString(item.id);
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: typeof item.text === "string" ? item.text : "",
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function threadItemToTimeline(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
@@ -1871,14 +1883,8 @@ export function threadItemToTimeline(
|
||||
switch (normalizedType) {
|
||||
case "userMessage":
|
||||
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
|
||||
case "agentMessage": {
|
||||
const messageId = nonEmptyString(normalizedItem.id);
|
||||
return {
|
||||
type: "assistant_message",
|
||||
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
case "agentMessage":
|
||||
return mapCodexAgentMessage(normalizedItem);
|
||||
case "plan":
|
||||
return mapCodexThreadPlanItem(normalizedItem);
|
||||
case "reasoning":
|
||||
@@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly asyncQuestions: CodexAsyncQuestions;
|
||||
private currentMode: string;
|
||||
private hasWorkflowModeOverride: boolean;
|
||||
private readonly providerOptions: CodexProviderOptions;
|
||||
@@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID;
|
||||
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||
this.config = config;
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = "fast";
|
||||
@@ -3799,6 +3807,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") {
|
||||
entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item;
|
||||
}
|
||||
if (entry.item.type === "user_message") {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||
}
|
||||
@@ -4464,13 +4475,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return Array.from(this.pendingPermissions.values());
|
||||
return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
if (this.asyncQuestions.hasPending(requestId)) {
|
||||
return this.respondToAsyncQuestion(requestId, response);
|
||||
}
|
||||
const pending = this.pendingPermissionHandlers.get(requestId);
|
||||
if (!pending) {
|
||||
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||
@@ -4564,6 +4578,33 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
pending.resolve({ answers: {} });
|
||||
}
|
||||
|
||||
private async respondToAsyncQuestion(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void> {
|
||||
const prepared = this.asyncQuestions.prepareResponse(requestId, response);
|
||||
let followUpPrompt = prepared.prompt;
|
||||
const expectedTurnId = this.activeForegroundTurnId;
|
||||
if (prepared.prompt && expectedTurnId) {
|
||||
const result = await this.steerActiveTurn(prepared.prompt, {
|
||||
expectedTurnId,
|
||||
clientMessageId: randomUUID(),
|
||||
});
|
||||
if (result.status !== "accepted") {
|
||||
throw new Error("The active Codex turn changed. Retry sending your answer.");
|
||||
}
|
||||
followUpPrompt = undefined;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: response,
|
||||
});
|
||||
return followUpPrompt ? { followUpPrompt } : undefined;
|
||||
}
|
||||
|
||||
private handlePlanPermissionResponse(params: {
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
@@ -4700,6 +4741,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
toolPolicy: this.config.toolPolicy,
|
||||
systemPrompt: this.config.systemPrompt,
|
||||
mcpServers: this.config.mcpServers,
|
||||
asyncQuestions: this.asyncQuestions.serialize(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4729,6 +4771,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
await this.loadPersistedHistory();
|
||||
this.reconcileAsyncQuestionsAfterRewind();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5903,6 +5946,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
error: parsed.errorMessage ?? "Codex turn failed",
|
||||
});
|
||||
} else if (parsed.status === "interrupted") {
|
||||
this.dismissInterruptedAsyncQuestions();
|
||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||
} else {
|
||||
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||
@@ -6255,6 +6299,43 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private receiveAsyncQuestion(threadId: string | null, item: unknown): void {
|
||||
if (threadId !== this.currentThreadId) return;
|
||||
const request = this.asyncQuestions.receive(item);
|
||||
if (request)
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
}
|
||||
|
||||
private dismissInterruptedAsyncQuestions(): void {
|
||||
const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" };
|
||||
for (const request of this.asyncQuestions.pending()) {
|
||||
const prepared = this.asyncQuestions.prepareResponse(request.id, resolution);
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId: request.id,
|
||||
resolution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileAsyncQuestionsAfterRewind(): void {
|
||||
const retainedIds = new Set(
|
||||
this.persistedHistory.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
|
||||
),
|
||||
);
|
||||
for (const requestId of this.asyncQuestions.retain(retainedIds)) {
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
requestId,
|
||||
resolution: { behavior: "deny", message: "Removed by rewind" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): void {
|
||||
@@ -6265,6 +6346,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { expect, test } from "vitest";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForTimelineToolCall,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
|
||||
const questionItem = {
|
||||
type: "agentMessage",
|
||||
id: "async-question-1",
|
||||
text: "Which color?\n- Blue\n- Green",
|
||||
phase: "final_answer",
|
||||
delivery: "async",
|
||||
questions: [{ title: "Which color?", options: ["Blue", "Green"] }],
|
||||
};
|
||||
|
||||
async function setup(metadata?: Record<string, unknown>, rejectSteer = false) {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/interrupt": () => ({}),
|
||||
"turn/steer": () => {
|
||||
if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } };
|
||||
return { turnId: "native-turn" };
|
||||
},
|
||||
"thread/read": () => ({ thread: { id: "thread-1", turns: [] } }),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
|
||||
metadata ? { sessionId: "thread-1", metadata } : null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await session.startTurn("Help me choose a color while you inspect the project.");
|
||||
const started = waitForNextEvent(session, "turn_started");
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" });
|
||||
await started;
|
||||
async function ask() {
|
||||
const shown = waitForTimelineToolCall(session, questionItem.id);
|
||||
appServer.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-1", turnId: "native-turn", item: questionItem },
|
||||
}) + "\n",
|
||||
);
|
||||
await shown;
|
||||
}
|
||||
async function say(text: string) {
|
||||
const shown = waitForNextEvent(
|
||||
session,
|
||||
"timeline",
|
||||
(event) => event.item.type === "assistant_message" && event.item.text.includes(text),
|
||||
);
|
||||
appServer.says({ threadId: "thread-1", text });
|
||||
await shown;
|
||||
}
|
||||
async function finish(status: "completed" | "interrupted" = "completed") {
|
||||
const finished = waitForNextEvent(
|
||||
session,
|
||||
status === "completed" ? "turn_completed" : "turn_canceled",
|
||||
);
|
||||
appServer.completeTurn({ status });
|
||||
await finished;
|
||||
}
|
||||
function allowAnswers() {
|
||||
rejectSteer = false;
|
||||
}
|
||||
return { session, appServer, events, ask, say, finish, allowAnswers };
|
||||
}
|
||||
|
||||
const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } };
|
||||
|
||||
async function setupRewind(fail = false) {
|
||||
const records = [
|
||||
{ item: { ...questionItem, id: "earlier-pending" } },
|
||||
{ item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] },
|
||||
{ item: { ...questionItem, id: "removed-pending" } },
|
||||
{ item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] },
|
||||
];
|
||||
function userMessage(id: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text: id }] };
|
||||
}
|
||||
const turns = [
|
||||
{
|
||||
id: "earlier-turn",
|
||||
items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)],
|
||||
},
|
||||
{
|
||||
id: "removed-turn",
|
||||
items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)],
|
||||
},
|
||||
];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => {
|
||||
const { threadId } = params as { threadId: string };
|
||||
return {
|
||||
thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) },
|
||||
};
|
||||
},
|
||||
...(fail
|
||||
? {
|
||||
"thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
|
||||
{ sessionId: "thread-1", metadata: { asyncQuestions: records } },
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
await session.connect();
|
||||
return { session, events, records };
|
||||
}
|
||||
|
||||
test("rewind removes only questions outside the remaining history, including after resume", async () => {
|
||||
const { session, events, records } = await setupRewind();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await session.revertConversation({ messageId: "rewind-here" });
|
||||
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
]);
|
||||
metadata = session.describePersistence()!.metadata;
|
||||
expect(metadata.asyncQuestions).toEqual(
|
||||
records.slice(0, 2).map((record) => ({
|
||||
resolution: record.resolution,
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: record.item.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
})),
|
||||
);
|
||||
expect(events.filter((e) => e.type === "permission_resolved")).toEqual([
|
||||
expect.objectContaining({ requestId: "permission-removed-pending" }),
|
||||
]);
|
||||
const history = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(
|
||||
history.some(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "tool_call" &&
|
||||
e.item.callId === "removed-pending",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
]);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed rewind preserves question state", async () => {
|
||||
const { session, events } = await setupRewind(true);
|
||||
try {
|
||||
const before = session.describePersistence();
|
||||
await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow(
|
||||
"Rewind failed",
|
||||
);
|
||||
expect(session.describePersistence()).toEqual(before);
|
||||
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
|
||||
"permission-earlier-pending",
|
||||
"permission-removed-pending",
|
||||
]);
|
||||
expect(events.some((e) => e.type === "permission_resolved")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function manage(session: CodexAppServerAgentSession) {
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: {
|
||||
provider: "codex",
|
||||
capabilities: session.capabilities,
|
||||
createSession: async () => session,
|
||||
resumeSession: async () => session,
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
},
|
||||
},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return { manager, agent };
|
||||
}
|
||||
|
||||
test("manager publishes and saves the provider state after rewind", async () => {
|
||||
const { session } = await setupRewind();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await manager.rewind(agent.id, "rewind-here", "conversation");
|
||||
const snapshot = manager.getAgent(agent.id)!;
|
||||
expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]);
|
||||
expect(snapshot.persistence).toEqual(session.describePersistence());
|
||||
expect(snapshot.persistence?.sessionId).toBe("forked-thread");
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("manager snapshots capture pending and answered question state before the turn ends", async () => {
|
||||
const { session, ask } = await setup();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
|
||||
{
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: questionItem.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
await manager.respondToPermission(agent.id, permission.id, answer);
|
||||
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
|
||||
{
|
||||
item: {
|
||||
type: "agentMessage",
|
||||
id: questionItem.id,
|
||||
delivery: "async",
|
||||
questions: questionItem.questions,
|
||||
},
|
||||
resolution: ["Green"],
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent answers deliver only one response to the active Codex turn", async () => {
|
||||
const { session, appServer, ask } = await setup();
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
const results = await Promise.allSettled([
|
||||
manager.respondToPermission(agent.id, permission.id, answer),
|
||||
manager.respondToPermission(agent.id, permission.id, answer),
|
||||
]);
|
||||
expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed submission releases the request so the user can retry", async () => {
|
||||
const { session, ask, allowAnswers } = await setup(undefined, true);
|
||||
const { manager, agent } = await manage(session);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow(
|
||||
"Delivery failed",
|
||||
);
|
||||
expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]);
|
||||
allowAnswers();
|
||||
await manager.respondToPermission(agent.id, permission.id, answer);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => {
|
||||
const { session, appServer, events, ask, say } = await setup();
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toMatchObject({
|
||||
kind: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
header: "Question 1",
|
||||
question: "Which color?",
|
||||
isOther: true,
|
||||
options: [{ label: "Blue" }, { label: "Green" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await say("I am still inspecting the project.");
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("still inspecting"),
|
||||
),
|
||||
).toBe(true);
|
||||
await session.respondToPermission(permission.id, answer);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject(
|
||||
[
|
||||
{
|
||||
params: {
|
||||
expectedTurnId: "native-turn",
|
||||
clientUserMessageId: expect.any(String),
|
||||
input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }],
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
events.some(
|
||||
(event) => event.type === "permission_resolved" && event.requestId === permission.id,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a failed answer pending for retry", async () => {
|
||||
const { session, ask } = await setup(undefined, true);
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow();
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => {
|
||||
const first = await setup();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await first.ask();
|
||||
expect(first.session.getPendingPermissions()).toHaveLength(1);
|
||||
first.session.subscribe((event) => {
|
||||
if (event.type === "turn_canceled") {
|
||||
metadata = first.session.describePersistence()!.metadata;
|
||||
}
|
||||
});
|
||||
await first.session.interrupt();
|
||||
await first.finish("interrupted");
|
||||
expect(first.session.getPendingPermissions()).toEqual([]);
|
||||
expect(metadata?.asyncQuestions).toEqual([
|
||||
expect.objectContaining({ resolution: "dismissed" }),
|
||||
]);
|
||||
expect(first.events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "permission_resolved",
|
||||
requestId: "permission-async-question-1",
|
||||
resolution: { behavior: "deny", message: "Interrupted" },
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await first.session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
await resumed.ask();
|
||||
expect(resumed.session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => {
|
||||
const { session, appServer, ask, finish } = await setup();
|
||||
try {
|
||||
await ask();
|
||||
const [permission] = session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await finish();
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
expect(await session.respondToPermission(permission.id, answer)).toEqual({
|
||||
followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen",
|
||||
});
|
||||
expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => {
|
||||
const first = await setup();
|
||||
let metadata: Record<string, unknown> | undefined;
|
||||
try {
|
||||
await first.ask();
|
||||
expect(first.session.getPendingPermissions()).toHaveLength(1);
|
||||
metadata = first.session.describePersistence()!.metadata;
|
||||
} finally {
|
||||
await first.session.close();
|
||||
}
|
||||
const resumed = await setup(metadata);
|
||||
try {
|
||||
const [permission] = resumed.session.getPendingPermissions();
|
||||
expect(permission).toBeDefined();
|
||||
await resumed.session.respondToPermission(permission.id, { behavior: "deny" });
|
||||
await resumed.ask();
|
||||
expect(resumed.session.getPendingPermissions()).toEqual([]);
|
||||
expect(
|
||||
resumed.appServer.requests().some((request) => request.method === "turn/interrupt"),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await resumed.session.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
ToolCallTimelineItem,
|
||||
} from "../../agent-sdk-types.js";
|
||||
|
||||
const QuestionSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
options: z.array(z.string().min(1)).nullish(),
|
||||
});
|
||||
const ItemSchema = z.object({
|
||||
type: z.literal("agentMessage"),
|
||||
id: z.string().min(1),
|
||||
delivery: z.literal("async"),
|
||||
questions: z.array(QuestionSchema).min(1),
|
||||
});
|
||||
const RecordSchema = z.object({
|
||||
item: ItemSchema,
|
||||
resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(),
|
||||
});
|
||||
type QuestionRecord = z.infer<typeof RecordSchema>;
|
||||
|
||||
function requestId(itemId: string): string {
|
||||
return `permission-${itemId}`;
|
||||
}
|
||||
|
||||
function toPermission(record: QuestionRecord): AgentPermissionRequest {
|
||||
return {
|
||||
id: requestId(record.item.id),
|
||||
provider: "codex",
|
||||
name: "request_user_input_async",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
input: {
|
||||
questions: record.item.questions.map((question, index) => ({
|
||||
id: String(index),
|
||||
header: `Question ${index + 1}`,
|
||||
question: question.title,
|
||||
options: (question.options ?? []).map((label) => ({ label })),
|
||||
isOther: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toTimeline(record: QuestionRecord): ToolCallTimelineItem {
|
||||
const answers = Array.isArray(record.resolution) ? record.resolution : undefined;
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: record.item.id,
|
||||
name: "request_user_input_async",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "brain",
|
||||
text:
|
||||
record.item.questions
|
||||
.map((question, index) =>
|
||||
[question.title, answers ? answers[index] : (question.options ?? []).join(", ")]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
.join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null {
|
||||
const parsed = ItemSchema.safeParse(item);
|
||||
return parsed.success ? toTimeline({ item: parsed.data }) : null;
|
||||
}
|
||||
|
||||
/** Codex emits these as completed messages; the outstanding answer belongs to the session. */
|
||||
export class CodexAsyncQuestions {
|
||||
private readonly records = new Map<string, QuestionRecord>();
|
||||
|
||||
constructor(saved: unknown) {
|
||||
const parsed = z.array(RecordSchema).safeParse(saved);
|
||||
if (parsed.success) {
|
||||
for (const record of parsed.data) this.records.set(requestId(record.item.id), record);
|
||||
}
|
||||
}
|
||||
|
||||
receive(item: unknown): AgentPermissionRequest | null {
|
||||
const parsed = ItemSchema.safeParse(item);
|
||||
if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null;
|
||||
const record = { item: parsed.data };
|
||||
this.records.set(requestId(parsed.data.id), record);
|
||||
return toPermission(record);
|
||||
}
|
||||
|
||||
pending(): AgentPermissionRequest[] {
|
||||
return Array.from(this.records.values())
|
||||
.filter((record) => record.resolution === undefined)
|
||||
.map(toPermission);
|
||||
}
|
||||
|
||||
hasPending(id: string): boolean {
|
||||
const record = this.records.get(id);
|
||||
return record !== undefined && record.resolution === undefined;
|
||||
}
|
||||
|
||||
prepareResponse(
|
||||
id: string,
|
||||
response: AgentPermissionResponse,
|
||||
): { prompt?: string; complete: () => ToolCallTimelineItem } {
|
||||
const record = this.records.get(id);
|
||||
if (!record || record.resolution !== undefined)
|
||||
throw new Error("Question is no longer pending");
|
||||
let resolution: QuestionRecord["resolution"] = "dismissed";
|
||||
let prompt: string | undefined;
|
||||
if (response.behavior === "allow") {
|
||||
const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers);
|
||||
resolution = record.item.questions.map((_, index) => {
|
||||
const answer = answers[`Question ${index + 1}`]?.trim();
|
||||
if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`);
|
||||
return answer;
|
||||
});
|
||||
const values = resolution;
|
||||
prompt =
|
||||
"Answers to your questions:\n\n" +
|
||||
record.item.questions
|
||||
.map((question, index) => `${question.title}\n${values[index]}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
complete: () => {
|
||||
record.resolution = resolution;
|
||||
return toTimeline(record);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
timeline(itemId: string): ToolCallTimelineItem | null {
|
||||
const record = this.records.get(requestId(itemId));
|
||||
return record ? toTimeline(record) : null;
|
||||
}
|
||||
|
||||
retain(itemIds: ReadonlySet<string>): string[] {
|
||||
const removedPendingIds: string[] = [];
|
||||
for (const [id, record] of this.records) {
|
||||
if (itemIds.has(record.item.id)) continue;
|
||||
this.records.delete(id);
|
||||
if (record.resolution === undefined) removedPendingIds.push(id);
|
||||
}
|
||||
return removedPendingIds;
|
||||
}
|
||||
|
||||
serialize(): unknown {
|
||||
return Array.from(this.records.values());
|
||||
}
|
||||
}
|
||||
@@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject {
|
||||
type StreamEventType = AgentStreamEvent["type"];
|
||||
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
|
||||
|
||||
function waitForNextEvent<TType extends StreamEventType>(
|
||||
export function waitForNextEvent<TType extends StreamEventType>(
|
||||
session: AgentSession,
|
||||
type: TType,
|
||||
accepts?: (event: StreamEventOfType<TType>) => boolean,
|
||||
|
||||
@@ -188,20 +188,21 @@ An environment or dynamic named-agent expression must have a finite set of possi
|
||||
|
||||
### Steps
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | Unique within the workflow. |
|
||||
| `environment` | yes | Literal environment name or finite expression resolving to one. |
|
||||
| `max_runtime` | yes | Step hard limit. |
|
||||
| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. |
|
||||
| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. |
|
||||
| `prompt` | yes | Ordered `text` and `include` blocks. |
|
||||
| `if` | no | Expression deciding whether the step runs. |
|
||||
| `env` | no | Environment variables from connection values. |
|
||||
| `output.schema` | no | JSON Schema for structured step output. |
|
||||
| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. |
|
||||
| `auto_archive` | no | Archive the agent after the step ends. |
|
||||
| `github` | no | Explicit GitHub authority for this step. |
|
||||
| Field | Required | Notes |
|
||||
| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | yes | Unique within the workflow. |
|
||||
| `environment` | yes | Literal environment name or finite expression resolving to one. |
|
||||
| `max_runtime` | yes | Step hard limit. |
|
||||
| `idle_timeout` | yes | Idle limit no longer than `max_runtime`. |
|
||||
| `startup_timeout` | no | Step startup waiting budget. See [startup timeout](/docs/hub/configuration#startup-timeout) for defaults and limits. |
|
||||
| `agent` | yes | Named agent, finite expression selecting a named agent, or complete static inline agent. |
|
||||
| `prompt` | yes | Ordered `text` and `include` blocks. |
|
||||
| `if` | no | Expression deciding whether the step runs. |
|
||||
| `env` | no | Environment variables from connection values. |
|
||||
| `output.schema` | no | JSON Schema for structured step output. |
|
||||
| `allow_outputs` | no | Provider output capabilities with optional `max` and `required`. |
|
||||
| `auto_archive` | no | Archive the agent after the step ends. |
|
||||
| `github` | no | Explicit GitHub authority for this step. |
|
||||
|
||||
An inline agent is static and complete:
|
||||
|
||||
|
||||
@@ -64,6 +64,23 @@ A Discord starter uses `discord.mention`, your Discord user ID, and `discord.rep
|
||||
|
||||
Setup asks before replacing the selected trigger file. It preserves other triggers and any existing legacy bundle. Read [Hub security](/docs/hub/security) before widening `from_users` or the agent's authority.
|
||||
|
||||
## Startup timeout
|
||||
|
||||
Hub waits up to **two minutes** for agent startup, including worktree creation, provider startup, and initial prompt acceptance. For a slower machine, set `run.startup_timeout` in the trigger YAML:
|
||||
|
||||
```yaml
|
||||
name: inspect
|
||||
on:
|
||||
manual.run: {}
|
||||
run:
|
||||
target: { daemon: devbox, cwd: /workspace/project }
|
||||
agent: { provider: codex, mode: full-access }
|
||||
startup_timeout: 5m
|
||||
prompt: Inspect this repository and summarize its current state.
|
||||
```
|
||||
|
||||
Use a positive duration in `ms`, `s`, `m`, or `h`, up to `24h`. Omitting the field uses `2m`. The existing `max_runtime` and `idle_timeout` limits still apply during startup and can expire sooner. This setting changes Hub's waiting budget; provider-specific timeouts remain in effect.
|
||||
|
||||
## Deploy from the CLI
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
@@ -96,6 +96,8 @@ worktree:
|
||||
|
||||
For agents it dispatched, Hub owns creation, reconnect recovery, output observation, and completion. Agents you start yourself are untouched.
|
||||
|
||||
Restarting Hub lets active agents continue working. Hub reconnects to the same agents and resumes tracking their output and completion. [GitHub credentials retain their original lifetime](/docs/hub/github#restarts-and-credential-lifetime).
|
||||
|
||||
If Hub loses the create response, or the daemon restarts mid-execution, Hub resends the same create intent with the same execution id. The daemon returns the existing agent rather than running the prompt again. An agent that closed or errored is recorded as interrupted; Hub never silently starts a second one.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -37,7 +37,7 @@ steps:
|
||||
${{ paseo.prompt }}
|
||||
```
|
||||
|
||||
The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends.
|
||||
The agent can use `git` and `gh` within the declared repositories and permissions. Hub mints the token when the step starts and revokes it when execution ends or its configured duration elapses.
|
||||
|
||||
## Fields
|
||||
|
||||
@@ -50,6 +50,12 @@ The agent can use `git` and `gh` within the declared repositories and permission
|
||||
|
||||
Requested authority cannot exceed the GitHub App installation. Activation and dispatch fail clearly when the connection, repository, or permissions cannot be resolved.
|
||||
|
||||
## Restarts and credential lifetime
|
||||
|
||||
Restarting Hub preserves active executions and their existing credentials. Hub reconnects to the same agent and restores the original credential deadlines. A restart does not mint a replacement token or extend its lifetime.
|
||||
|
||||
If Hub is unavailable when a shorter duration elapses, it revokes the overdue token when it returns. GitHub’s own expiry still limits the token lifetime to one hour. Revocation failures are retried after recovery.
|
||||
|
||||
## Agent environment
|
||||
|
||||
Hub supplies `GH_TOKEN` and process-scoped git configuration through environment variables:
|
||||
@@ -73,4 +79,8 @@ env:
|
||||
SOME_TOKEN: "${{ paseo.connections.some-connection.token }}"
|
||||
```
|
||||
|
||||
Hub resolves the value for the step and does not persist it. See [Hub security](/docs/hub/security) for provider and host boundaries.
|
||||
Hub persists resolved values as private execution data so it can recover after a restart. Authored configuration retains the expressions.
|
||||
|
||||
These values are stored in the Hub database without application-level encryption. Protect database access, storage, and backups as credentials. Hub deletes the execution authority record when the execution becomes terminal; token lease records remain until revocation succeeds or the token expires, so interrupted cleanup can resume. This cleanup is not a guarantee that every copy is erased: retained agent/session data and database backups may still contain resolved values. Token revocation ends access; it does not erase those copies. Backup retention and deletion remain the operator’s responsibility.
|
||||
|
||||
See [Hub security](/docs/hub/security) for provider and host boundaries.
|
||||
|
||||
Reference in New Issue
Block a user