Skip to content
Closed
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
163 changes: 163 additions & 0 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4850,6 +4850,7 @@ describe("Discord controller flows", () => {
const harness = await createControllerHarness();
const { controller } = harness;
const { sendMessageTelegram } = harness;
const activeKey = `telegram::default::${TEST_TELEGRAM_PEER_ID}::`;
let resolveResult: ((value: unknown) => void) | undefined;
const result = new Promise((resolve) => {
resolveResult = resolve;
Expand Down Expand Up @@ -4925,11 +4926,173 @@ describe("Discord controller flows", () => {
});
await Promise.resolve();
await Promise.resolve();
expect((controller as any).activeRuns.get(activeKey)?.mode).toBe("plan");
expect((controller as any).store.getPendingRequestById("req-plan-1")).not.toBeNull();
} finally {
vi.useRealTimers();
}
});

it("restores the active plan run when questionnaire input arrives after result cleanup", async () => {
const { controller } = await createControllerHarness();
const conversation = {
channel: "telegram",
accountId: "default",
conversationId: TEST_TELEGRAM_PEER_ID,
} as const;
const activeKey = `telegram::default::${TEST_TELEGRAM_PEER_ID}::`;
let onPendingInput: ((state: any) => Promise<void>) | undefined;
const submitPendingInputPayload = vi.fn(async () => true);
(controller as any).client.startTurn = vi.fn((params: any) => {
onPendingInput = params.onPendingInput;
return {
result: Promise.resolve({ threadId: "thread-1" }),
getThreadId: () => "thread-1",
queueMessage: vi.fn(async () => false),
interrupt: vi.fn(async () => {}),
isAwaitingInput: () => false,
submitPendingInput: vi.fn(async () => false),
submitPendingInputPayload,
};
});

await (controller as any).startPlan({
conversation,
binding: null,
workspaceDir: "/repo/openclaw",
prompt: "Ask the breakfast question.",
});

await Promise.resolve();
await Promise.resolve();
(controller as any).activeRuns.delete(activeKey);
expect((controller as any).activeRuns.get(activeKey)).toBeUndefined();

await onPendingInput?.({
requestId: "req-plan-race",
options: [],
expiresAt: Date.now() + 7 * 24 * 60 * 60_000,
method: "item/tool/requestUserInput",
questionnaire: {
currentIndex: 0,
questions: [
{
index: 0,
id: "breakfast",
header: "Breakfast",
prompt: "Do you like cereal?",
options: [
{ key: "A", label: "Yes", description: "Choose yes." },
],
guidance: [],
},
],
answers: [null],
responseMode: "structured",
},
});

expect((controller as any).activeRuns.get(activeKey)?.mode).toBe("plan");
const callback = (controller as any).store.snapshot.callbacks.find((entry: any) =>
entry.kind === "pending-questionnaire" &&
entry.requestId === "req-plan-race" &&
entry.action === "select"
);
expect(callback).toBeTruthy();
const reply = vi.fn(async () => {});

await controller.handleTelegramInteractive({
...conversation,
callback: {
payload: callback.token,
},
respond: {
clearButtons: vi.fn(async () => {}),
reply,
editMessage: vi.fn(async () => {}),
},
} as any);

expect(submitPendingInputPayload).toHaveBeenCalledWith({
answers: {
breakfast: { answers: ["Yes"] },
},
});
expect(reply).toHaveBeenCalledWith({
text: "Recorded your answers and sent them to Codex.",
});
expect((controller as any).activeRuns.get(activeKey)).toBeUndefined();
expect((controller as any).store.getPendingRequestById("req-plan-race")).toBeNull();
});

it("ignores late questionnaire input from an older run when a newer run is already active", async () => {
const { controller } = await createControllerHarness();
const conversation = {
channel: "telegram",
accountId: "default",
conversationId: TEST_TELEGRAM_PEER_ID,
} as const;
const activeKey = `telegram::default::${TEST_TELEGRAM_PEER_ID}::`;
const newerRun = {
getThreadId: () => "thread-new",
queueMessage: vi.fn(async () => false),
interrupt: vi.fn(async () => {}),
isAwaitingInput: () => false,
submitPendingInput: vi.fn(async () => false),
submitPendingInputPayload: vi.fn(async () => false),
};
const olderRun = {
getThreadId: () => "thread-old",
queueMessage: vi.fn(async () => false),
interrupt: vi.fn(async () => {}),
isAwaitingInput: () => false,
submitPendingInput: vi.fn(async () => false),
submitPendingInputPayload: vi.fn(async () => false),
};

(controller as any).activeRuns.set(activeKey, {
conversation,
workspaceDir: "/repo/new",
mode: "plan",
profile: "default",
handle: newerRun,
});

await (controller as any).handlePendingInputState(
conversation,
"/repo/old",
"plan",
"default",
{
requestId: "req-old-run",
options: [],
expiresAt: Date.now() + 7 * 24 * 60 * 60_000,
method: "item/tool/requestUserInput",
questionnaire: {
currentIndex: 0,
questions: [
{
index: 0,
id: "breakfast",
header: "Breakfast",
prompt: "Do you like cereal?",
options: [
{ key: "A", label: "Yes", description: "Choose yes." },
],
guidance: [],
},
],
answers: [null],
responseMode: "structured",
},
},
olderRun as any,
);

expect((controller as any).activeRuns.get(activeKey)?.handle).toBe(newerRun);
expect((controller as any).store.getPendingRequestById("req-old-run")).toBeNull();
});

it("tells the user to log back in when Codex reports OpenAI auth is required", async () => {
const { controller, clientMock, sendMessageTelegram } = await createControllerHarness();
clientMock.readAccount.mockResolvedValue({
Expand Down
Loading