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
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,81 @@ import {
authorizeRuntimeHostOperation,
createRuntimeHostConnectionAuthority,
} from '../server/connection-authority.js';
import { decodeScheduledTaskMutateInput } from '../protocol/scheduled-task.js';
import {
decodeScheduledTaskMutateInput,
decodeScheduledTaskMutateResult,
} from '../protocol/scheduled-task.js';

describe('ScheduledTask protocol', () => {
test('preserves Agent tool mode through mutations and catalog reads, including legacy templates', () => {
for (const toolMode of [undefined, 'direct', 'code_mode'] as const) {
const effect = agentRunEffect('project-1');
assert.equal(effect.kind, 'agent_run');
if (effect.kind !== 'agent_run') return;
const task = {
...scheduledTask('agent-task'),
effect: {
...effect,
execution: {
...effect.execution,
...(toolMode === undefined ? {} : { toolMode }),
},
},
};
const create = {
kind: 'create',
input: {
title: task.title,
intentBody: task.intent.body,
schedule: task.schedule,
effect: task.effect,
},
};
const update = { kind: 'update', taskId: task.id, patch: { effect: task.effect } };
const result = { kind: 'task', task };
const page = {
kind: 'page',
revision: 1,
tasks: [scheduledTask('notification'), task],
nextCursor: null,
};
assert.deepEqual(decodeScheduledTaskMutateInput(create), create);
assert.deepEqual(decodeScheduledTaskMutateInput(update), update);
assert.deepEqual(decodeScheduledTaskMutateResult(result), result);
assert.deepEqual(decodeScheduledTaskQueryResult(result), result);
assert.deepEqual(decodeScheduledTaskQueryResult(page), page);
}
});

test('rejects invalid Agent tool modes on writes and reads', () => {
const effect = agentRunEffect('project-1');
if (effect.kind !== 'agent_run') return;
for (const toolMode of ['unknown', null, 42]) {
const invalidEffect = { ...effect, execution: { ...effect.execution, toolMode } };
assert.throws(
() =>
decodeScheduledTaskMutateInput({
kind: 'create',
input: {
title: 'Invalid',
intentBody: '',
schedule: { kind: 'once', runAt: 1 },
effect: invalidEffect,
},
}),
/Invalid ScheduledTask tool mode/u,
);
assert.throws(
() =>
decodeScheduledTaskQueryResult({
kind: 'task',
task: { ...scheduledTask('invalid'), effect: invalidEffect },
}),
/Invalid ScheduledTask tool mode/u,
);
}
});

test('requires Host-path authority only when a mutation submits a Host path', () => {
const authority = createRuntimeHostConnectionAuthority({
principalKind: 'remote_owner',
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 189 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 190 as const;
// 190: ScheduledTask execution templates preserve toolMode; older peers reject it.
// 189: Remove form_interaction transcript messages and the transcript_changed close reason.
// 188: Session capability replacement can require an atomic idle root check;
// complete MCP configuration identities fence conflicting providers across ACP clients.
Expand Down
9 changes: 8 additions & 1 deletion packages/runtime-host/src/protocol/scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
type UpdateScheduledTaskInput,
} from '@maka/core/scheduled-task';
import { isThinkingLevel } from '@maka/core/model-thinking';
import { isToolMode } from '@maka/core/tool-mode';
import {
requireCount,
requireEncodedByteLimit,
Expand Down Expand Up @@ -554,7 +555,7 @@ function decodeExecution(
'collaborationMode',
'orchestrationMode',
],
['projectId', 'thinkingLevel', 'backend', 'llmConnectionId'],
['projectId', 'thinkingLevel', 'backend', 'llmConnectionId', 'toolMode'],
);
if (requireConnectionId && !Object.hasOwn(execution, 'llmConnectionId')) {
throw invalidProtocolFrame('ScheduledTask execution requires Connection id');
Expand All @@ -571,6 +572,9 @@ function decodeExecution(
if (Object.hasOwn(execution, 'thinkingLevel') && !isThinkingLevel(execution.thinkingLevel)) {
throw invalidProtocolFrame('Invalid ScheduledTask thinking level');
}
if (Object.hasOwn(execution, 'toolMode') && !isToolMode(execution.toolMode)) {
throw invalidProtocolFrame('Invalid ScheduledTask tool mode');
}
if (
Object.hasOwn(execution, 'projectId') &&
execution.projectId !== null &&
Expand All @@ -580,6 +584,9 @@ function decodeExecution(
}
return {
cwd: boundedText(execution.cwd, 'ScheduledTask cwd', 4_096, true),
...(Object.hasOwn(execution, 'toolMode')
? { toolMode: execution.toolMode as ScheduledTaskExecutionTemplate['toolMode'] }
: {}),
...(Object.hasOwn(execution, 'projectId')
? { projectId: execution.projectId as string | null }
: {}),
Expand Down
163 changes: 157 additions & 6 deletions packages/runtime/src/__tests__/scheduled-task-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,26 @@ const NOW = Date.UTC(2026, 8, 24, 1, 42, 45);
const TEN_AM_SHANGHAI = Date.UTC(2026, 8, 24, 2, 0, 0);

function authority(created: Partial<ScheduledTask> = {}): ScheduledTaskToolAuthority {
let latest: ScheduledTask | undefined;
return {
create: async (input) =>
({
create: async (input) => {
latest = {
id: 'task-1',
title: input.title,
nextFireAt: input.schedule.kind === 'once' ? input.schedule.runAt : null,
effect: { kind: 'notify', channel: 'local' },
...created,
}) as ScheduledTask,
list: async () => [],
} as ScheduledTask;
return latest;
},
list: async () => (latest ? [latest] : []),
pause: async () => ({ error: 'unused' }),
resume: async () => ({ error: 'unused' }),
remove: async () => ({ error: 'unused' }),
};
}

const ctx = { sessionId: 'session-1' } as MakaToolContext;
const ctx = { cwd: '/project', sessionId: 'session-1' } as MakaToolContext;

test('formats Host time with an explicit offset and zone', () => {
assert.equal(
Expand Down Expand Up @@ -89,9 +92,157 @@ test('create echoes the local fire time next to the epoch', async () => {
),
);
assert.deepEqual(result.split('\n'), [
'Scheduled task created: Tidy Downloads (task-1)',
'Scheduled task created and verified in Maka catalog: Tidy Downloads (task-1)',
`nextFireAt=${TEN_AM_SHANGHAI} (2026-09-24T10:00:00+08:00 (Asia/Shanghai))`,
'effect=notify',
'cwd=/project',
`now=${NOW} (2026-09-24T09:42:45+08:00 (Asia/Shanghai))`,
]);
});

const verificationTask: ScheduledTask = {
id: 'weekly-report',
title: 'Weekly work report',
intent: { kind: 'text', body: 'Generate a weekly report' },
schedule: { kind: 'cron', expression: '0 17 * * 5', startAt: 1 },
effect: {
kind: 'agent_run',
execution: {
cwd: '/project',
llmConnectionId: 'connection',
llmConnectionSlug: 'model',
model: 'test',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
status: 'active',
nextFireAt: 1790931600000,
lastFireAt: null,
fireCount: 0,
maxFires: null,
expiresAt: null,
createdBy: { kind: 'agent', sessionId: 'session' },
createdAt: 1,
updatedAt: 1,
runs: [],
lastError: null,
};
const verificationInput = {
mode: 'create',
title: verificationTask.title,
intentBody: verificationTask.intent.body,
schedule: { kind: 'cron', expression: '0 17 * * 5' },
effect: 'agent_run',
};

function verificationFixture(overrides: Partial<ScheduledTaskToolAuthority> = {}) {
let creates = 0;
const authority: ScheduledTaskToolAuthority = {
create: async () => {
creates++;
return verificationTask;
},
list: async () => [verificationTask],
pause: async () => verificationTask,
resume: async () => verificationTask,
remove: async () => ({ ok: true }),
...overrides,
};
const context: MakaToolContext = {
cwd: '/project',
sessionId: 'session',
turnId: 'turn',
toolCallId: 'call',
abortSignal: new AbortController().signal,
emitOutput: () => {},
};
return { tool: buildScheduledTaskTool({ authority }), context, creates: () => creates };
}

test('create checks the catalog and reports the persisted execution directory', async () => {
let reads = 0;
const fixture = verificationFixture({
list: async () => {
reads++;
return [verificationTask];
},
});
const result = await fixture.tool.impl(verificationInput, {
...fixture.context,
cwd: '/other-context',
});
assert.match(String(result), /created and verified in Maka catalog/);
assert.match(String(result), /cwd=\/project/);
assert.equal(reads, 1);
assert.equal(fixture.creates(), 1);
});

for (const kind of ['missing', 'unavailable'] as const) {
test(`create warns of possible persistence when catalog is ${kind}`, async () => {
const fixture = verificationFixture({
list: async () => {
if (kind === 'unavailable') throw new Error('offline');
return [];
},
});
const result = String(await fixture.tool.impl(verificationInput, fixture.context));
assert.match(result, /catalog verification (could not find|was unavailable)/);
assert.match(result, /weekly-report/);
assert.match(result, /before retrying to avoid duplicates/);
assert.doesNotMatch(result, /created and verified/);
assert.equal(fixture.creates(), 1);
});
}

for (const cwd of ['', ' ']) {
test(`rejects unresolved project cwd ${JSON.stringify(cwd)} before mutation`, async () => {
const fixture = verificationFixture();
assert.match(
String(await fixture.tool.impl(verificationInput, { ...fixture.context, cwd })),
/requires a project working directory/,
);
assert.equal(fixture.creates(), 0);
});
}

test('allows a filesystem-root workspace when the Host provides it', async () => {
const fixture = verificationFixture();
const result = String(
await fixture.tool.impl(verificationInput, { ...fixture.context, cwd: '/' }),
);
assert.match(result, /created and verified in Maka catalog/);
assert.equal(fixture.creates(), 1);
});

test('notifications do not require a project directory', async () => {
const fixture = verificationFixture();
await fixture.tool.impl(
{ ...verificationInput, effect: 'notify_local' },
{ ...fixture.context, cwd: '' },
);
assert.equal(fixture.creates(), 1);
});

test('list is explicitly a query and never creates tasks', async () => {
const fixture = verificationFixture();
assert.match(
String(await fixture.tool.impl({ mode: 'list' }, fixture.context)),
/Scheduled task catalog \(1\)/,
);
assert.equal(fixture.creates(), 0);
});

test('creation error is preserved without querying the catalog', async () => {
const fixture = verificationFixture({
create: async () => ({ error: 'Session was not found' }),
list: async () => {
assert.fail('must not query on failure');
},
});
assert.match(
String(await fixture.tool.impl(verificationInput, fixture.context)),
/Session was not found/,
);
});
29 changes: 27 additions & 2 deletions packages/runtime/src/scheduled-task-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* Agent-facing access to the Runtime Host-owned ScheduledTask catalog.
*/

import { resolve } from 'node:path';
import { z } from 'zod';
import {
SCHEDULED_TASK_CRON_MAX_CHARS,
Expand Down Expand Up @@ -150,6 +151,7 @@ export function buildScheduledTaskTool(deps: {
const tasks = await deps.authority.list();
if (tasks.length === 0) return `No scheduled tasks.\n${clockLine()}`;
return [
`Scheduled task catalog (${tasks.length}):`,
...tasks.map(
(task) =>
`- ${task.id} | ${task.title} | ${task.status} | next=${fireLine(task.nextFireAt)} | effect=${task.effect.kind}`,
Expand All @@ -161,16 +163,39 @@ export function buildScheduledTaskTool(deps: {
if (!input.title || !input.intentBody || !input.schedule) {
return `create requires title, intentBody, and schedule\n${clockLine()}`;
}
const effect = input.effect ?? 'session_resume';
const resolvedCwd = ctx.cwd?.trim() ? resolve(ctx.cwd) : '';
if (effect !== 'notify_local' && !resolvedCwd) {
return `create ${effect} requires a project working directory\n${clockLine()}`;
}
const result = await deps.authority.create({
title: input.title,
intentBody: input.intentBody,
schedule: input.schedule,
effect: input.effect ?? 'session_resume',
effect,
sessionId,
...(input.maxFires !== undefined ? { maxFires: input.maxFires } : {}),
});
if ('error' in result) return `${result.error}\n${clockLine()}`;
return `Scheduled task created: ${result.title} (${result.id})\nnextFireAt=${fireLine(result.nextFireAt)}\neffect=${result.effect.kind}\n${clockLine()}`;
let catalog: readonly ScheduledTask[];
try {
catalog = await deps.authority.list();
} catch {
return `Scheduled task may have been created, but catalog verification was unavailable for ${result.id}; query mode=list before retrying to avoid duplicates\n${clockLine()}`;
}
const persisted = catalog.find((task) => task.id === result.id);
if (!persisted) {
return `Scheduled task may have been created, but catalog verification could not find ${result.id}; query mode=list before retrying to avoid duplicates\n${clockLine()}`;
}
const taskCwd =
persisted.effect.kind === 'agent_run' ? persisted.effect.execution.cwd : resolvedCwd;
return [
`Scheduled task created and verified in Maka catalog: ${persisted.title} (${persisted.id})`,
`nextFireAt=${fireLine(persisted.nextFireAt)}`,
`effect=${persisted.effect.kind}`,
`cwd=${taskCwd}`,
clockLine(),
].join('\n');
}
if (!input.id) return `${input.mode} requires id`;
if (input.mode === 'pause') {
Expand Down