Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,17 @@ export async function legacySetSessionModel(
export type SessionSteerRequest = {
sessionId: SessionId;
prompt: ContentBlock[];
_meta?: {
[key: string]: unknown;
steering?: {
idleBehavior?: "promptRequired";
};
} | null;
}

export type SessionSteeringResponse = {
outcome: "injected" | "startedNewTurn" | "failed";
outcome: "injected" | "startedNewTurn" | "failed" | "promptRequired";
reason?: "noRunningTurn";
}

export type SessionSteeringExtRequest = {
Expand Down
22 changes: 21 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,9 @@ export class CodexAcpServer {
return {outcome: "injected"};
}
}
if (params._meta?.steering?.idleBehavior === "promptRequired") {
return {outcome: "promptRequired", reason: "noRunningTurn"};
}
return await this.startNewTurnFromSteering(params);
}

Expand Down Expand Up @@ -1431,13 +1434,30 @@ export class CodexAcpServer {
private parseSessionSteerParams(params: Record<string, unknown>): SessionSteerRequest {
const sessionId = params["sessionId"];
const prompt = params["prompt"];
const meta = params["_meta"];
if (typeof sessionId !== "string" || !Array.isArray(prompt)) {
throw RequestError.invalidParams();
}
return {
const steering = meta && typeof meta === "object"
? (meta as Record<string, unknown>)["steering"]
: undefined;
const idleBehavior = steering && typeof steering === "object"
? (steering as Record<string, unknown>)["idleBehavior"]
: undefined;
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
}
const request = {
sessionId: sessionId,
prompt: prompt as acp.ContentBlock[],
};
if (meta === undefined) {
return request;
}
return {
...request,
_meta: meta as Exclude<SessionSteerRequest["_meta"], undefined>,
};
}

private createSessionConfigOptions(sessionState: SessionState): Array<acp.SessionConfigOption> {
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/CodexACPAgent/steer-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,50 @@ describe('_session/steering', () => {
});
});

it('returns promptRequired without starting a turn when the host owns the idle fallback', async () => {
const mockFixture = createCodexMockTestFixture();
const sessionState = createTestSessionState();
vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState);
const turnStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnStart");

await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, {
sessionId: "session-id",
prompt: [{type: "text", text: "too late for the previous turn"}],
_meta: {steering: {idleBehavior: "promptRequired"}},
})).resolves.toEqual({outcome: "promptRequired", reason: "noRunningTurn"});

expect(turnStartSpy).not.toHaveBeenCalled();
});

it('returns promptRequired when the tracked turn ends during injection', async () => {
const {mockFixture, sessionState, turnCompleted} = startActiveTurn();
vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer").mockImplementation(async () => {
turnCompleted.resolve({
threadId: "session-id",
turn: createTurn("turn-id", "completed"),
});
throw Object.assign(new Error("Internal error"), {
data: {details: "no active turn to steer"},
});
});

const promptPromise = mockFixture.getCodexAcpAgent().prompt({
sessionId: "session-id",
prompt: [{type: "text", text: "long running prompt"}],
});
await vi.waitFor(() => {
expect(sessionState.currentTurnId).toBe("turn-id");
});

await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, {
sessionId: "session-id",
prompt: [{type: "text", text: "racing follow-up"}],
_meta: {steering: {idleBehavior: "promptRequired"}},
})).resolves.toEqual({outcome: "promptRequired", reason: "noRunningTurn"});
await expect(promptPromise).resolves.toMatchObject({stopReason: "end_turn"});
expect(sessionState.currentTurnId).toBeNull();
});

it('starts a new turn when Codex reports that the tracked turn is no longer active', async () => {
const {mockFixture, sessionState, turnCompleted} = startActiveTurn();
const nextTurnCompleted = deferred<TurnCompletedNotification>();
Expand Down Expand Up @@ -212,6 +256,16 @@ describe('_session/steering', () => {
})).rejects.toThrow(RequestError);
});

it('rejects unsupported idle steering behavior', async () => {
const mockFixture = createCodexMockTestFixture();

await expect(mockFixture.getCodexAcpAgent().extMethod(SESSION_STEERING_METHOD, {
sessionId: "session-id",
prompt: [{type: "text", text: "do not guess how to handle this"}],
_meta: {steering: {idleBehavior: "unsupported"}},
})).rejects.toThrow("unsupported steering idleBehavior");
});

it('rejects image input when the model does not support it', async () => {
const {mockFixture} = startActiveTurn({supportedInputModalities: ["text"]});
const turnSteerSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "turnSteer");
Expand Down