Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ ade chat read session-id --limit 20 --text
ade chat message session-id --kind auto --text "status/context"
ade chat steer session-id --text "active-turn context"
ade chat schedules session-id --pause # pause this chat's durable wakeups/cron/loops (omit flag to inspect, --resume to re-arm)
ade chat scheduled-work list [session-id] --all # list durable jobs; --all includes recent terminal history
ade chat scheduled-work cancel session-id job-id # cancel one job; Claude-native jobs request CronDelete in the owning chat
ade chat wait session-id --for idle --timeout-ms 600000
ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume
ade chat handoff session-id --model openai/gpt-5.6-sol --note "focus on tests" # brief handoff; add --target-lane <lane-id> to hand off into another lane
Expand Down
65 changes: 65 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,23 @@ function createRuntime() {
lastActivityAt: "2026-03-17T19:00:00.000Z",
createdAt: "2026-03-17T19:00:00.000Z",
})),
listScheduledWork: vi.fn(async ({ sessionId, includeTerminal }: {
sessionId?: string;
includeTerminal?: boolean;
}) => [{
id: "cron-1",
sessionId: sessionId ?? "chat-1",
kind: "cron",
status: includeTerminal ? "cancelled" : "scheduled",
}]),
cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: false,
providerCancellationConfirmed: true,
})),
getChatTranscript: vi.fn(async ({ sessionId }: { sessionId: string }) => ({
sessionId,
entries: [{ role: "assistant", text: "hello", timestamp: "2026-03-17T19:00:00.000Z" }],
Expand Down Expand Up @@ -2743,6 +2760,35 @@ describe("adeRpcServer", () => {
expect(chatSummary?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.getSessionSummary).toHaveBeenCalledWith("chat-1");

const scheduledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: " chat-1 ", includeTerminal: true },
});
expect(scheduledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
includeTerminal: true,
});
expect(scheduledWork.structuredContent.result).toEqual([
expect.objectContaining({ id: "cron-1", sessionId: "chat-1", status: "cancelled" }),
]);

const allScheduledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
});
expect(allScheduledWork?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({});

const cancelledWork = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: " chat-1 ", scheduleId: " cron-1 " },
});
expect(cancelledWork.isError).toBe(true);
expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled();

const aiStatus = await callTool(handler, "run_ade_action", {
domain: "ai",
action: "getStatus",
Expand Down Expand Up @@ -3079,6 +3125,25 @@ describe("adeRpcServer", () => {
text: "own-chat write",
});

const deniedScheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-2", scheduleId: "wake-2" },
});
expect(deniedScheduledWorkCancel.isError).toBe(true);
expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled();

const ownScheduledWorkCancel = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "wake-1" },
});
expect(ownScheduledWorkCancel?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "wake-1",
});

const peerMessage = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "messageSession",
Expand Down
17 changes: 15 additions & 2 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2387,7 +2387,12 @@ function scopeChatAdeActionArgs(
chatArgs: Record<string, unknown>,
): Record<string, unknown> {
const method = `run_ade_action:chat.${action}`;
if (action !== "readTranscript" && action !== "sendMessage") return chatArgs;
if (
action !== "readTranscript"
&& action !== "sendMessage"
&& action !== "cancelScheduledWork"
Comment thread
arul28 marked this conversation as resolved.
) return chatArgs;
if (isUnboundAdeCliCaller(session)) return chatArgs;

const scopedArgs = { ...chatArgs };
const callerChatSessionId = asOptionalTrimmedString(session.identity.chatSessionId);
Expand Down Expand Up @@ -3492,7 +3497,15 @@ async function runTool(args: {
action,
requireObjectArgsForScopedAdeAction(domain, action, argsList, hasScalarArg, rawObjectArgs),
);
} else if (!callerIsCto && domain === "chat" && (action === "readTranscript" || action === "sendMessage")) {
} else if (
!callerIsCto
&& domain === "chat"
&& (
action === "readTranscript"
|| action === "sendMessage"
|| action === "cancelScheduledWork"
)
) {
scopedObjectArgs = scopeChatAdeActionArgs(
session,
action,
Expand Down
56 changes: 56 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2909,6 +2909,62 @@ describe("ADE CLI", () => {
expect(() => buildCliPlan(["chat", "schedules", "chat-1", "--pause", "--resume"])).toThrow(
/either --pause or --resume/,
);

const list = expectExecutePlan(buildCliPlan(["chat", "scheduled-work", "list", "chat-1", "--all"]));
expect(list.label).toBe("chat scheduled-work list");
expect(list.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-1", includeTerminal: true },
},
});

const listAllChats = expectExecutePlan(buildCliPlan(["chat", "scheduled-work", "list", "--all"]));
expect(listAllChats.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: {
domain: "chat",
action: "listScheduledWork",
args: { includeTerminal: true },
},
});

for (const alias of ["schedule", "schedules"]) {
expect(expectExecutePlan(buildCliPlan(["chat", alias, "list", "chat-1"])).steps[0]?.params)
.toMatchObject({
arguments: {
action: "listScheduledWork",
args: { sessionId: "chat-1" },
},
});
expect(expectExecutePlan(buildCliPlan(["chat", alias, "cancel", "chat-1", "cron-1"])).steps[0]?.params)
.toMatchObject({
arguments: {
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "cron-1" },
},
});
}

const cancel = expectExecutePlan(buildCliPlan([
"chat",
"scheduled-work",
"cancel",
"chat-1",
"cron-1",
]));
expect(cancel.label).toBe("chat scheduled-work cancel");
expect(cancel.steps[0]?.params).toMatchObject({
arguments: {
domain: "chat",
action: "cancelScheduledWork",
args: { sessionId: "chat-1", scheduleId: "cron-1" },
},
});
expect(() => buildCliPlan(["chat", "scheduled-work", "cancel", "chat-1"])).toThrow(
/scheduleId is required/,
);
});

it("rejects prototype-sensitive generic ADE action arg paths", () => {
Expand Down
46 changes: 46 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,8 @@ const HELP_BY_COMMAND: Record<string, string> = {
$ ade chat subagents <session> --text List child agents for a chat
$ ade chat schedules <session> --pause Pause this chat's durable wakeups/cron/loops
$ ade chat schedules <session> Inspect pause state + next armed wake (--resume to re-arm)
$ ade chat scheduled-work list [session] List durable jobs (--all includes recent history)
$ ade chat scheduled-work cancel <session> <id> Cancel one job; Claude crons also request CronDelete
$ ade new chat --mode cli --lane <lane> --provider claude --reasoning-effort ultracode --prompt "fix"
Start a tracked provider CLI session
$ ade chat attach-linear-issue <session> --issue-id ENG-431
Expand Down Expand Up @@ -6442,6 +6444,14 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "linear-issues" ||
sub === "list-linear-issues" ||
sub === "issues";
const scheduledWorkOperation = (
sub === "scheduled-work"
|| sub === "schedules"
|| sub === "schedule"
)
&& (args[0] === "list" || args[0] === "cancel")
? firstStandalonePositional(args)
: null;
const sessionId =
readValue(args, ["--session", "--session-id"]) ??
(sub !== "create" && sub !== "list" && !linearSessionSub
Expand Down Expand Up @@ -7086,6 +7096,42 @@ function buildChatPlan(args: string[]): CliPlan {
sub === "schedule" ||
sub === "scheduled-work"
) {
if (scheduledWorkOperation === "list") {
return {
kind: "execute",
label: "chat scheduled-work list",
steps: [
actionStep(
"result",
"chat",
"listScheduledWork",
collectGenericObjectArgs(args, {
...(sessionId ? { sessionId } : {}),
...(readFlag(args, ["--all", "--include-terminal"]) ? { includeTerminal: true } : {}),
}),
),
],
};
}
if (scheduledWorkOperation === "cancel") {
const targetSession = requireValue(sessionId, "sessionId");
const scheduleId = requireValue(firstStandalonePositional(args), "scheduleId");
return {
kind: "execute",
label: "chat scheduled-work cancel",
steps: [
actionStep(
"result",
"chat",
"cancelScheduledWork",
collectGenericObjectArgs(args, {
sessionId: targetSession,
scheduleId,
}),
),
],
};
}
// Per-chat scheduled-work control: pause/resume this chat's durable
// wakeups/cron/loop schedules, or inspect (no flag) the current pause
// state + next armed wake via getSessionSummary.
Expand Down
35 changes: 35 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,40 @@ describe("createSyncRemoteCommandService", () => {
]);
});

it("routes scheduled-work cancellation through the non-queueable mobile command", async () => {
const cancelScheduledWork = vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: true,
providerCancellationConfirmed: true,
}));
const { service } = createService({ agentChatService: { cancelScheduledWork } });

expect(service.getDescriptor("chat.cancelScheduledWork")).toEqual({
action: "chat.cancelScheduledWork",
scope: "project",
policy: { viewerAllowed: true, queueable: false },
});
await expect(service.execute(makePayload("chat.cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: "cron-1",
}))).resolves.toMatchObject({
schedule: { id: "cron-1", sessionId: "chat-1", status: "cancelled" },
providerCancellationConfirmed: true,
});
expect(cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});

await expect(service.execute(makePayload("chat.cancelScheduledWork", {
sessionId: "chat-1",
}))).rejects.toThrow("chat.cancelScheduledWork requires scheduleId.");
expect(cancelScheduledWork).toHaveBeenCalledTimes(1);
});

it("rejects unsupported Codex recovery actions before invoking chat", async () => {
const recoverCodexTurn = vi.fn();
const { service } = createService({ agentChatService: { recoverCodexTurn } });
Expand Down Expand Up @@ -1062,6 +1096,7 @@ describe("createSyncRemoteCommandService", () => {
"work.deleteSession",
"work.getSessionDelta",
"chat.getSlashCommands",
"chat.cancelScheduledWork",
"chat.getParallelLaunchState",
"chat.setParallelLaunchState",
"chat.handoff",
Expand Down
5 changes: 5 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3808,6 +3808,11 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio
});
register("chat.getSummary", { viewerAllowed: true }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId));
register("chat.cancelScheduledWork", { viewerAllowed: true, queueable: false }, async (payload) =>
requireService(args.agentChatService, "Agent chat service not available.").cancelScheduledWork({
Comment thread
arul28 marked this conversation as resolved.
sessionId: requireString(payload.sessionId, "chat.cancelScheduledWork requires sessionId."),
scheduleId: requireString(payload.scheduleId, "chat.cancelScheduledWork requires scheduleId."),
}));
register("chat.getChatEventHistory", { viewerAllowed: true }, async (payload): Promise<AgentChatEventHistorySnapshot> => {
const agentChatService = requireService(args.agentChatService, "Agent chat service not available.");
const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId.");
Expand Down
7 changes: 3 additions & 4 deletions apps/ade-cli/src/tuiClient/__tests__/appPolling.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,9 @@ describe("AdeCodeApp polling", () => {
([domain, action]) => domain === "analytics" && action === "capture",
);

expect(analyticsCalls().map(([, , input]) => (input as { event?: string }).event)).toEqual([
"ade_app_opened",
"ade_screen_viewed",
]);
const analyticsEvents = analyticsCalls().map(([, , input]) => (input as { event?: string }).event);
expect(analyticsEvents).toHaveLength(2);
expect(new Set(analyticsEvents)).toEqual(new Set(["ade_app_opened", "ade_screen_viewed"]));
const initialAnalyticsCount = analyticsCalls().length;

await act(async () => {
Expand Down
45 changes: 45 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,51 @@ describe("deriveChatInfoSnapshot", () => {
]);
});

it("reconciles transcript events with ADE-managed scheduled work", () => {
const snapshot = deriveChatInfoSnapshot({
events: [
env("2026-07-07T12:00:00.000Z", {
type: "scheduled_work_update",
id: "stale-cron",
kind: "cron",
status: "scheduled",
title: "Stale transcript cron",
durable: true,
}, 1),
],
activeSession: session({
provider: "claude",
scheduledWork: [{
id: "managed-cron",
sessionId: "session-1",
kind: "cron",
status: "paused",
title: "Managed cron",
prompt: "Check CI",
cron: "7,27,47 * * * *",
createdAt: "2026-07-07T12:05:00.000Z",
durable: true,
cancellable: true,
}],
}),
provider: "claude",
modelLabel: "claude-opus-4-8",
laneLabel: "lane",
snapshots: [],
tokenStats: null,
goal: null,
streaming: false,
});

expect(snapshot.scheduledWork).toHaveLength(1);
expect(snapshot.scheduledWork[0]).toEqual(expect.objectContaining({
id: "managed-cron",
status: "paused",
cancellable: true,
}));
expect(snapshot.scheduledWork.some((item) => item.id === "stale-cron")).toBe(false);
});

it("partitions background_task work into backgroundWork, keeping schedule kinds in scheduledWork", () => {
const snapshot = deriveChatInfoSnapshot({
events: [
Expand Down
Loading