Skip to content
Merged
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
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
122 changes: 121 additions & 1 deletion 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 @@ -2696,7 +2713,7 @@ describe("adeRpcServer", () => {
it("invokes ADE actions dynamically and returns status hints", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "agent-1", role: "agent" });
await initialize(handler, { callerId: "ade-cli:123", role: "agent" });

const response = await callTool(handler, "run_ade_action", {
domain: "git",
Expand Down Expand Up @@ -2743,6 +2760,38 @@ 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).toBeUndefined();
expect(fixture.runtime.agentChatService.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});

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

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

const ownScheduledWorkList = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { includeTerminal: true },
});
expect(ownScheduledWorkList?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
includeTerminal: true,
});

const ctoHandler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(ctoHandler, {
callerId: "cto-1",
role: "cto",
chatSessionId: "chat-1",
});
const ctoScheduledWorkList = await callTool(ctoHandler, "run_ade_action", {
domain: "chat",
action: "listScheduledWork",
args: { sessionId: "chat-2", includeTerminal: true },
});
expect(ctoScheduledWorkList?.isError).toBeUndefined();
expect(fixture.runtime.agentChatService.listScheduledWork).toHaveBeenLastCalledWith({
sessionId: "chat-2",
includeTerminal: true,
});

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 Expand Up @@ -3646,6 +3750,22 @@ describe("adeRpcServer", () => {
sessionId: "chat-2",
text: "external write",
});

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

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

it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => {
Expand Down
24 changes: 21 additions & 3 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2387,11 +2387,20 @@ 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 !== "listScheduledWork"
&& 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);
if (session.identity.role === "external" && !callerChatSessionId) return scopedArgs;
if (session.identity.role === "external" && !callerChatSessionId) {
if (action === "readTranscript" || action === "sendMessage") return scopedArgs;
chatAccessDenied(method);
}

const requestedSessionId = asOptionalTrimmedString(scopedArgs.sessionId);
if (!callerChatSessionId || (requestedSessionId && requestedSessionId !== callerChatSessionId)) {
Expand Down Expand Up @@ -3492,7 +3501,16 @@ 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 === "listScheduledWork"
|| 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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe("PersonalChatScope", () => {
interrupt: vi.fn(async () => undefined),
respondToInput: vi.fn(async () => undefined),
approveToolUse: vi.fn(async () => undefined),
cancelScheduledWork: vi.fn(async ({ sessionId, scheduleId }: {
sessionId: string;
scheduleId: string;
}) => ({
schedule: { id: scheduleId, sessionId, status: "cancelled" },
providerCancellationRequested: true,
providerCancellationConfirmed: true,
})),
updateSession: vi.fn(async () => summary),
archiveSession: vi.fn(async () => undefined),
unarchiveSession: vi.fn(async () => undefined),
Expand Down Expand Up @@ -145,11 +153,44 @@ describe("PersonalChatScope", () => {
expect(service.sendMessage).not.toHaveBeenCalled();
});

it("cancels scheduled work only for an owned personal session", async () => {
const { createRuntime, service } = fixture();
const scope = new PersonalChatScope({ createRuntime });

await expect(scope.call("cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: "cron-1",
})).resolves.toMatchObject({
action: "cancelScheduledWork",
result: {
schedule: { id: "cron-1", sessionId: "chat-1", status: "cancelled" },
providerCancellationConfirmed: true,
},
});
expect(service.cancelScheduledWork).toHaveBeenCalledWith({
sessionId: "chat-1",
scheduleId: "cron-1",
});

await expect(scope.call("cancelScheduledWork", {
sessionId: "chat-1",
scheduleId: " ",
})).rejects.toThrow("scheduleId is required");
expect(service.cancelScheduledWork).toHaveBeenCalledTimes(1);
});

it("exposes only the hard allowlisted personal-chat actions", () => {
const scope = new PersonalChatScope();
const capabilities = scope.capabilities();
expect(capabilities.version).toBe(1);
expect(capabilities.actions).toEqual(expect.arrayContaining(["list", "create", "send", "updateSession", "delete"]));
expect(capabilities.actions).toEqual(expect.arrayContaining([
"list",
"create",
"send",
"cancelScheduledWork",
"updateSession",
"delete",
]));
expect(capabilities.actions).not.toContain("projects.list");
});

Expand Down
Loading
Loading