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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ Ode is a Slack bot that bridges messages to OpenCode for AI-assisted coding.
- Prod: `./start.sh`
- User: `@ode <message>` and `stop`

## One-time Tasks (`ode task`)
- A Task is a one-shot scheduled prompt: the scheduler fires it exactly once at an absolute time, then posts the agent result back to the channel (or thread, if anchored).
- CLI:
- `ode task create --time <ISO8601> --channel <channelId> --message "<prompt>" [--thread <threadId>] [--title <title>] [--agent <agentId>] [--run-now]`
- `ode task list [--status pending|running|success|failed|cancelled] [--json]`
- `ode task show <id>` / `ode task cancel <id>` / `ode task delete <id>` / `ode task run <id>`
- When `--thread` is set, the scheduler reuses the existing thread's session so the agent wakes up with full context. When `--thread` is omitted, the task posts as a new channel message under a synthetic thread (`task:{id}`).
- Agents should prefer scheduling a Task instead of blocking on long waits (deploys, overnight builds, approvals): schedule the follow-up and return.
- Persistence: SQLite at `~/.config/ode/inbox.db` (table `tasks`); scheduler polls every 10s and uses `UPDATE ... WHERE status='pending'` for cross-process idempotency.
- HTTP API mirrors the CLI under `/api/tasks*`; the Web UI lives at Settings → Tasks.

## Bun conventions
- Use Bun instead of Node.js
- Run: `bun run src/index.ts`
Expand Down
20 changes: 18 additions & 2 deletions packages/agents/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,23 @@ function rememberSessionProvider(sessionId: string, providerId: AgentProviderId)
sessionProviders.set(sessionId, providerId);
}

export function createAgentAdapter(): AgentAdapter {
export type AgentAdapterOptions = {
/**
* Optional per-adapter provider override. When set, `getOrCreateSession`
* uses this provider instead of the channel's configured agent. Downstream
* calls (sendMessage, abort, subscribe, ...) remain keyed by the session id
* as usual, since `getOrCreateSession` writes the chosen provider into the
* `sessionProviders` map. Intended for schedulers that carry a per-job
* agent override (e.g. one-time tasks).
*/
providerOverride?: AgentProviderId | null;
};

export function createAgentAdapter(options: AgentAdapterOptions = {}): AgentAdapter {
const { providerOverride } = options;
const resolveProviderForChannel = (channelId: string): AgentProviderId =>
providerOverride ?? getProviderForChannel(channelId);

return {
supportsEventStream: true,
getProviderForSession(sessionId) {
Expand All @@ -33,7 +49,7 @@ export function createAgentAdapter(): AgentAdapter {
return getAgentProviderLabel(providerId);
},
async getOrCreateSession(channelId, threadId, cwd, env) {
const providerId = getProviderForChannel(channelId);
const providerId = resolveProviderForChannel(channelId);
const provider = getAgentProvider(providerId);
const result = await provider.getOrCreateSession(channelId, threadId, cwd, env);
rememberSessionProvider(result.sessionId, providerId);
Expand Down
8 changes: 8 additions & 0 deletions packages/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export function buildSystemPrompt(slack?: SlackContext): string {
lines.push("- When sharing tasks, put each item on its own line");
lines.push("- Use four states: * not started, ♻️ in progress, ✅ done, 🚫 cancelled");
lines.push("- If you include a task list, keep the tasks you have done at the top of the response");
lines.push("");
lines.push("ONE-TIME SCHEDULED TASKS:");
lines.push("- Ode provides a one-shot task scheduler for follow-ups that need to fire at a specific time.");
lines.push("- Use it when you need to wait on something that may take minutes, hours, or days (deploys, nightly builds, external approvals). Schedule a task and return instead of blocking the conversation.");
lines.push("- Create via CLI: `ode task create --time <ISO8601> --channel <channelId> [--thread <threadId>] --message \"<prompt>\" [--agent <agentId>]`.");
lines.push("- `--time` accepts ISO 8601 (e.g. `2026-04-19T09:00:00+08:00`). `--thread` is optional; when set, the task reuses this thread's session to keep context; when omitted, the task posts as a new channel message.");
lines.push("- When scheduling a follow-up for the current conversation, pass the current channel and thread so the agent wakes up with the same session history.");
lines.push("- Manage tasks with `ode task list`, `ode task show <id>`, `ode task cancel <id>`, `ode task delete <id>`. Tasks persist across restarts.");

const channelSystemMessage = slack.channelSystemMessage?.trim();
if (channelSystemMessage) {
Expand Down
2 changes: 1 addition & 1 deletion packages/config/local/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type MessageDetailKind =

export type MessageDetailStatus = "pending" | "completed" | "failed";

export type MessageThreadSourceKind = "user" | "cron_job";
export type MessageThreadSourceKind = "user" | "cron_job" | "task";

export interface MessageThreadSummary {
id: string;
Expand Down
291 changes: 291 additions & 0 deletions packages/config/local/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { invalidateOdeConfigCache, ODE_CONFIG_FILE } from "./ode-store";
import {
cancelTask,
clearTasksForTests,
closeTaskDatabaseForTests,
createTask,
deleteTask,
getTaskById,
listDueTasks,
listTasks,
markTaskCompleted,
markTaskFailed,
markTaskTriggered,
updateTask,
} from "./tasks";

// We reuse the real `~/.config/ode/ode.json` path (resolved at module load)
// but swap its contents for test fixtures and restore them on teardown.
// The inbox SQLite DB is redirected to a temp dir via ODE_INBOX_DB_FILE so
// test data never touches the user's real inbox.db.
let tempDir: string;
let originalConfigEnv: string | undefined;
let originalConfigContent: string | null;
let originalConfigExisted: boolean;

function writeTestOdeConfig(): void {
const config = {
user: {},
workspaces: [
{
id: "ws-test",
name: "Test Workspace",
type: "slack",
channelDetails: [
{ id: "C_TEST", name: "general" },
{ id: "C_OTHER", name: "random" },
],
},
],
};
fs.mkdirSync(path.dirname(ODE_CONFIG_FILE), { recursive: true });
fs.writeFileSync(ODE_CONFIG_FILE, JSON.stringify(config));
invalidateOdeConfigCache();
}

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ode-tasks-test-"));
originalConfigEnv = process.env.ODE_INBOX_DB_FILE;
process.env.ODE_INBOX_DB_FILE = path.join(tempDir, "inbox.db");

originalConfigExisted = fs.existsSync(ODE_CONFIG_FILE);
originalConfigContent = originalConfigExisted ? fs.readFileSync(ODE_CONFIG_FILE, "utf-8") : null;
writeTestOdeConfig();

closeTaskDatabaseForTests();
clearTasksForTests();
});

afterEach(() => {
closeTaskDatabaseForTests();
if (originalConfigEnv === undefined) {
delete process.env.ODE_INBOX_DB_FILE;
} else {
process.env.ODE_INBOX_DB_FILE = originalConfigEnv;
}

// Restore the real ode.json so the user's config isn't left in a test state.
try {
if (originalConfigExisted && originalConfigContent !== null) {
fs.writeFileSync(ODE_CONFIG_FILE, originalConfigContent);
} else {
fs.rmSync(ODE_CONFIG_FILE, { force: true });
}
} catch {
// Best effort.
}
invalidateOdeConfigCache();

try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best effort.
}
});

describe("tasks storage", () => {
test("createTask persists fields and resolves channel snapshot", () => {
const scheduledAt = Date.now() + 60_000;
const task = createTask({
title: "Check deploy",
scheduledAt,
channelId: "C_TEST",
threadId: "1234.5678",
messageText: "Check deployment status",
agent: "opencode",
});

expect(task.id).toBeTruthy();
expect(task.title).toBe("Check deploy");
expect(task.scheduledAt).toBe(scheduledAt);
expect(task.platform).toBe("slack");
expect(task.workspaceId).toBe("ws-test");
expect(task.workspaceName).toBe("Test Workspace");
expect(task.channelId).toBe("C_TEST");
expect(task.channelName).toBe("general");
expect(task.threadId).toBe("1234.5678");
expect(task.agent).toBe("opencode");
expect(task.status).toBe("pending");
expect(task.lastError).toBeNull();
});

test("createTask rejects unknown channel", () => {
expect(() =>
createTask({
title: "x",
scheduledAt: Date.now() + 1000,
channelId: "C_UNKNOWN",
messageText: "hi",
}),
).toThrow(/Channel not found/);
});

test("createTask normalizes seconds-valued scheduledAt to milliseconds", () => {
const seconds = Math.floor(Date.now() / 1000) + 60;
const task = createTask({
title: "s",
scheduledAt: seconds,
channelId: "C_TEST",
messageText: "hi",
});
expect(task.scheduledAt).toBe(seconds * 1000);
});

test("listDueTasks returns only pending tasks at or before now", () => {
const now = Date.now();
const past = createTask({
title: "past",
scheduledAt: now - 10_000,
channelId: "C_TEST",
messageText: "a",
});
const future = createTask({
title: "future",
scheduledAt: now + 60_000,
channelId: "C_TEST",
messageText: "b",
});

const due = listDueTasks(now);
expect(due.map((t) => t.id)).toContain(past.id);
expect(due.map((t) => t.id)).not.toContain(future.id);
});

test("markTaskTriggered is atomic: first caller wins, second caller is no-op", () => {
const task = createTask({
title: "race",
scheduledAt: Date.now() - 1000,
channelId: "C_TEST",
messageText: "race me",
});

const first = markTaskTriggered(task.id);
const second = markTaskTriggered(task.id);
expect(first).toBe(true);
expect(second).toBe(false);

const updated = getTaskById(task.id);
expect(updated?.status).toBe("running");
expect(updated?.triggeredAt).not.toBeNull();
});

test("markTaskCompleted and markTaskFailed set terminal status", () => {
const a = createTask({ title: "ok", scheduledAt: Date.now(), channelId: "C_TEST", messageText: "x" });
const b = createTask({ title: "err", scheduledAt: Date.now(), channelId: "C_TEST", messageText: "y" });
markTaskTriggered(a.id);
markTaskCompleted(a.id);
markTaskTriggered(b.id);
markTaskFailed(b.id, "boom");

expect(getTaskById(a.id)?.status).toBe("success");
expect(getTaskById(b.id)?.status).toBe("failed");
expect(getTaskById(b.id)?.lastError).toBe("boom");
});

test("cancelTask only cancels pending tasks", () => {
const task = createTask({ title: "c", scheduledAt: Date.now() + 60_000, channelId: "C_TEST", messageText: "hi" });
expect(cancelTask(task.id)).toBe(true);
expect(getTaskById(task.id)?.status).toBe("cancelled");
// Second cancel is a no-op.
expect(cancelTask(task.id)).toBe(false);

// Cannot cancel a running or completed task.
const running = createTask({ title: "r", scheduledAt: Date.now(), channelId: "C_TEST", messageText: "r" });
markTaskTriggered(running.id);
expect(cancelTask(running.id)).toBe(false);
expect(getTaskById(running.id)?.status).toBe("running");
});

test("updateTask rejects edits on non-pending tasks", () => {
const task = createTask({ title: "u", scheduledAt: Date.now() + 60_000, channelId: "C_TEST", messageText: "hi" });
markTaskTriggered(task.id);
expect(() => updateTask(task.id, { title: "nope" })).toThrow(/pending/);
});

test("updateTask preserves unspecified fields", () => {
const task = createTask({
title: "u2",
scheduledAt: Date.now() + 60_000,
channelId: "C_TEST",
threadId: "T1",
messageText: "original",
agent: "opencode",
});
const updated = updateTask(task.id, { messageText: "new text" });
expect(updated.messageText).toBe("new text");
expect(updated.title).toBe(task.title);
expect(updated.channelId).toBe("C_TEST");
expect(updated.threadId).toBe("T1");
expect(updated.agent).toBe("opencode");
expect(updated.scheduledAt).toBe(task.scheduledAt);
});

test("deleteTask removes the record", () => {
const task = createTask({ title: "d", scheduledAt: Date.now(), channelId: "C_TEST", messageText: "x" });
deleteTask(task.id);
expect(getTaskById(task.id)).toBeNull();
});

test("listTasks orders running first, then pending by scheduled time", () => {
const now = Date.now();
const later = createTask({ title: "later", scheduledAt: now + 120_000, channelId: "C_TEST", messageText: "l" });
const sooner = createTask({ title: "sooner", scheduledAt: now + 60_000, channelId: "C_TEST", messageText: "s" });
const runningTask = createTask({ title: "running", scheduledAt: now, channelId: "C_TEST", messageText: "r" });
markTaskTriggered(runningTask.id);

const list = listTasks();
expect(list[0]?.id).toBe(runningTask.id);
const pendingOrder = list.filter((t) => t.status === "pending").map((t) => t.id);
expect(pendingOrder).toEqual([sooner.id, later.id]);
});

test("createTask rejects unsupported agent ids", () => {
expect(() =>
createTask({
title: "bad-agent",
scheduledAt: Date.now() + 60_000,
channelId: "C_TEST",
messageText: "hi",
agent: "not-a-real-agent",
}),
).toThrow(/Unsupported agent/);
});

test("createTask normalizes agent casing and accepts known ids", () => {
const task = createTask({
title: "case",
scheduledAt: Date.now() + 60_000,
channelId: "C_TEST",
messageText: "hi",
agent: "Codex",
});
expect(task.agent).toBe("codex");
});

test("createTask treats empty agent string as null (channel default)", () => {
const task = createTask({
title: "default",
scheduledAt: Date.now() + 60_000,
channelId: "C_TEST",
messageText: "hi",
agent: " ",
});
expect(task.agent).toBeNull();
});

test("updateTask rejects unsupported agent ids", () => {
const task = createTask({
title: "u-agent",
scheduledAt: Date.now() + 60_000,
channelId: "C_TEST",
messageText: "hi",
agent: "opencode",
});
expect(() => updateTask(task.id, { agent: "claude-code-beta" })).toThrow(/Unsupported agent/);
expect(getTaskById(task.id)?.agent).toBe("opencode");
});
});
Loading
Loading