diff --git a/packages/opencode/migration/20260321160000_add_teams/migration.sql b/packages/opencode/migration/20260321160000_add_teams/migration.sql new file mode 100644 index 000000000000..9cec987f8e24 --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/migration.sql @@ -0,0 +1,47 @@ +CREATE TABLE `team` ( + `id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `name` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_member` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `team_id` text NOT NULL, + `session_id` text NOT NULL, + `agent` text NOT NULL, + `role` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `team_task` ( + `id` text PRIMARY KEY NOT NULL, + `team_id` text NOT NULL, + `subject` text NOT NULL, + `description` text, + `owner` text, + `status` text DEFAULT 'pending' NOT NULL, + `metadata` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE TABLE `agent_memory` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `agent` text NOT NULL, + `content` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint +CREATE INDEX `team_session_idx` ON `team` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_member_team_idx` ON `team_member` (`team_id`);--> statement-breakpoint +CREATE INDEX `team_member_session_idx` ON `team_member` (`session_id`);--> statement-breakpoint +CREATE INDEX `team_task_team_idx` ON `team_task` (`team_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `agent_memory_project_agent_idx` ON `agent_memory` (`project_id`,`agent`); \ No newline at end of file diff --git a/packages/opencode/migration/20260321160000_add_teams/snapshot.json b/packages/opencode/migration/20260321160000_add_teams/snapshot.json new file mode 100644 index 000000000000..1c1e5292d9cc --- /dev/null +++ b/packages/opencode/migration/20260321160000_add_teams/snapshot.json @@ -0,0 +1,7 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "add-teams-migration", + "prevIds": [], + "ddl": [] +} diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2ae18aaaed5c..b415626e131e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -45,6 +45,7 @@ export namespace Agent { prompt: z.string().optional(), options: z.record(z.string(), z.any()), steps: z.number().int().positive().optional(), + memory: z.enum(["none", "local"]).optional(), }) .meta({ ref: "Agent", @@ -256,6 +257,7 @@ export namespace Agent { item.hidden = value.hidden ?? item.hidden item.name = value.name ?? item.name item.steps = value.steps ?? item.steps + item.memory = value.memory ?? item.memory item.options = mergeDeep(item.options, value.options ?? {}) item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } diff --git a/packages/opencode/src/agent/memory.ts b/packages/opencode/src/agent/memory.ts new file mode 100644 index 000000000000..693608738779 --- /dev/null +++ b/packages/opencode/src/agent/memory.ts @@ -0,0 +1,109 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and } from "../storage/db" +import { AgentMemoryTable } from "../team/team.sql" +import { MemoryID } from "../team/schema" +import { Instance } from "../project/instance" +import { Log } from "../util/log" + +const log = Log.create({ service: "agent.memory" }) + +const MAX_SIZE = 102_400 // 100KB + +export namespace AgentMemory { + export const Info = z + .object({ + id: MemoryID.zod, + projectID: z.string(), + agent: z.string(), + content: z.string(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "AgentMemory" }) + export type Info = z.infer + + export const Event = { + Updated: BusEvent.define( + "agent.memory.updated", + z.object({ + agent: z.string(), + projectID: z.string(), + }), + ), + } + + function toInfo(row: typeof AgentMemoryTable.$inferSelect): Info { + return { + id: row.id, + projectID: row.project_id, + agent: row.agent, + content: row.content, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function read(agent: string): Info | undefined { + const pid = Instance.project.id + const row = Database.use((db) => + db + .select() + .from(AgentMemoryTable) + .where(and(eq(AgentMemoryTable.project_id, pid), eq(AgentMemoryTable.agent, agent))) + .get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function write(agent: string, content: string) { + if (Buffer.byteLength(content, "utf8") > MAX_SIZE) { + content = Buffer.from(content, "utf8").subarray(0, MAX_SIZE).toString("utf8") + log.warn("memory truncated to 100KB", { agent }) + } + const pid = Instance.project.id + const now = Date.now() + const existing = read(agent) + if (existing) { + Database.use((db) => + db + .update(AgentMemoryTable) + .set({ content, time_updated: now }) + .where(eq(AgentMemoryTable.id, existing.id)) + .run(), + ) + } else { + const id = MemoryID.ascending() + Database.use((db) => + db + .insert(AgentMemoryTable) + .values({ + id, + project_id: pid, + agent, + content, + time_created: now, + time_updated: now, + }) + .run(), + ) + } + log.info("written", { agent, projectID: pid }) + Database.effect(() => Bus.publish(Event.Updated, { agent, projectID: pid })) + } + + export function append(agent: string, content: string) { + const existing = read(agent) + if (existing) { + write(agent, existing.content + "\n\n" + content) + } else { + write(agent, content) + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 3b296a927aa4..6c8f0c72c997 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -18,6 +18,27 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" + +export interface TeamMember { + teamID: string + sessionID: string + agent: string + role: "lead" | "member" + status: "active" | "completed" | "failed" | "cancelled" +} + +export interface TeamInfo { + id: string + sessionID: string + name: string + status: "active" | "disbanded" + time: { created: number; updated: number } +} + +export interface TeamWithMembers { + team: TeamInfo + members: TeamMember[] +} import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" @@ -72,6 +93,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [key: string]: McpResource } formatter: FormatterStatus[] + teams: TeamWithMembers[] vcs: VcsInfo | undefined path: Path workspaceList: Workspace[] @@ -100,6 +122,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, mcp_resource: {}, formatter: [], + teams: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, workspaceList: [], @@ -113,8 +136,64 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("workspaceList", reconcile(result.data)) } + async function syncTeams() { + const result = (await sdk + .fetch(`${sdk.url}/team/active`) + .then((r) => r.json()) + .catch(() => undefined)) as TeamWithMembers[] | undefined + if (!result) return + setStore("teams", reconcile(result)) + } + sdk.event.listen((e) => { const event = e.details + // Handle team events (not yet in SDK types) + const type = event.type as string + if (type === "team.created") { + const team = (event as any).properties.team as TeamInfo + setStore( + "teams", + produce((draft) => { + draft.push({ team, members: [] }) + }), + ) + return + } + if (type === "team.disbanded") { + const teamID = (event as any).properties.teamID as string + setStore( + "teams", + produce((draft) => { + const idx = draft.findIndex((t) => t.team.id === teamID) + if (idx !== -1) draft.splice(idx, 1) + }), + ) + return + } + if (type === "team.member.added") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (entry) entry.members.push(member) + }), + ) + return + } + if (type === "team.member.updated") { + const member = (event as any).properties.member as TeamMember + setStore( + "teams", + produce((draft) => { + const entry = draft.find((t) => t.team.id === member.teamID) + if (!entry) return + const idx = entry.members.findIndex((m) => m.sessionID === member.sessionID) + if (idx !== -1) entry.members[idx] = member + }), + ) + return + } switch (event.type) { case "server.instance.disposed": bootstrap() @@ -423,6 +502,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))), syncWorkspaces(), + syncTeams(), ]).then(() => { setStore("status", "complete") }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080a..081e40659b4b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,4 +1,4 @@ -import { useSync } from "@tui/context/sync" +import { useSync, type TeamWithMembers } from "@tui/context/sync" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" @@ -25,6 +25,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff: true, todo: true, lsp: true, + teams: true, }) // Sort MCP servers alphabetically for consistent display order @@ -60,6 +61,17 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { } }) + const teams = createMemo(() => sync.data.teams) + const agents = createMemo(() => { + const all: { agent: string; status: string; team: string }[] = [] + for (const t of teams()) { + for (const m of t.members) { + if (m.role === "member") all.push({ agent: m.agent, status: m.status, team: t.team.name }) + } + } + return all + }) + const directory = useDirectory() const kv = useKV() @@ -106,6 +118,71 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent + 0}> + + setExpanded("teams", !expanded.teams)}> + {expanded.teams ? "▼" : "▶"} + + Teams + + + {" "} + ({teams().length} active, {agents().filter((a) => a.status === "active").length} agents) + + + + + + + {(entry) => ( + + + {entry.team.name} + + m.role === "member")}> + {(member) => ( + + + )[member.status], + }} + > + {member.status === "active" + ? "◌" + : member.status === "completed" + ? "●" + : member.status === "failed" + ? "✕" + : "○"} + + + @{member.agent}{" "} + + + working + done + failed + cancelled + + + + + )} + + + )} + + + + 0}> { @@ -762,6 +766,7 @@ export namespace Config { "permission", "disable", "tools", + "memory", ]) // Extract unknown properties into options @@ -1205,6 +1210,24 @@ export namespace Config { .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), }) .optional(), + team: z + .object({ + max_agents: z + .number() + .int() + .positive() + .optional() + .default(10) + .describe("Maximum number of concurrent background agents (default: 10)"), + member_timeout: z + .number() + .int() + .positive() + .optional() + .default(300_000) + .describe("Timeout in milliseconds for team members waiting for messages (default: 300000 = 5 minutes)"), + }) + .optional(), experimental: z .object({ disable_paste_summary: z.boolean().optional(), diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 9e324962bf7a..78f73bf995d1 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,9 @@ export namespace Identifier { pty: "pty", tool: "tool", workspace: "wrk", + team: "tem", + team_task: "ttk", + memory: "mem", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/server/routes/team.ts b/packages/opencode/src/server/routes/team.ts new file mode 100644 index 000000000000..ea7c2528d75d --- /dev/null +++ b/packages/opencode/src/server/routes/team.ts @@ -0,0 +1,226 @@ +import { Hono } from "hono" +import { describeRoute, validator, resolver } from "hono-openapi" +import z from "zod" +import { Team } from "../../team" +import { TeamTask } from "../../team/task" +import { TeamID, TeamTaskID } from "../../team/schema" +import { SessionID } from "@/session/schema" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const TeamRoutes = lazy(() => + new Hono() + .get( + "/active", + describeRoute({ + summary: "List active teams with members", + description: "Get all active teams and their members for the TUI status panel.", + operationId: "team.active", + responses: { + 200: { + description: "Active teams with members", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + team: Team.Info, + members: Team.Member.array(), + }), + ), + ), + }, + }, + }, + }, + }), + async (c) => { + const teams = Team.active() + const result = teams.map((team) => ({ + team, + members: Team.members(team.id), + })) + return c.json(result) + }, + ) + .get( + "/", + describeRoute({ + summary: "List teams", + description: "Get teams for a session.", + operationId: "team.list", + responses: { + 200: { + description: "List of teams", + content: { + "application/json": { + schema: resolver(Team.Info.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + session_id: z.string().meta({ description: "Session ID of the team lead" }), + }), + ), + async (c) => { + const query = c.req.valid("query") + const teams = Team.bySession(SessionID.make(query.session_id)) + return c.json(teams) + }, + ) + .get( + "/:id", + describeRoute({ + summary: "Get team", + operationId: "team.get", + responses: { + 200: { + description: "Team details", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const team = Team.get(id) + if (!team) return c.json({ error: "Team not found" }, 404) + return c.json(team) + }, + ) + .get( + "/:id/members", + describeRoute({ + summary: "List team members", + operationId: "team.members", + responses: { + 200: { + description: "Team members", + content: { + "application/json": { + schema: resolver(Team.Member.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(Team.members(id)) + }, + ) + .post( + "/", + describeRoute({ + summary: "Create team", + operationId: "team.create", + responses: { + 200: { + description: "Created team", + content: { + "application/json": { + schema: resolver(Team.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + name: z.string(), + session_id: z.string(), + agent: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const team = Team.create({ + name: body.name, + sessionID: SessionID.make(body.session_id), + agent: body.agent, + }) + return c.json(team) + }, + ) + .post( + "/:id/disband", + describeRoute({ + summary: "Disband team", + operationId: "team.disband", + responses: { + 200: { + description: "Team disbanded", + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + Team.disband(id) + return c.json({ ok: true }) + }, + ) + .get( + "/:id/tasks", + describeRoute({ + summary: "List team tasks", + operationId: "team.task.list", + responses: { + 200: { + description: "Team tasks", + content: { + "application/json": { + schema: resolver(TeamTask.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const id = TeamID.make(c.req.param("id")) + return c.json(TeamTask.list(id)) + }, + ) + .post( + "/:id/tasks", + describeRoute({ + summary: "Create team task", + operationId: "team.task.create", + responses: { + 200: { + description: "Created task", + content: { + "application/json": { + schema: resolver(TeamTask.Info), + }, + }, + }, + }, + }), + validator( + "json", + z.object({ + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + }), + ), + async (c) => { + const id = TeamID.make(c.req.param("id")) + const body = c.req.valid("json") + const task = TeamTask.create({ + teamID: id, + ...body, + }) + return c.json(task) + }, + ), +) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index d53074a80fea..5d31ddac8164 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -43,6 +43,7 @@ import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" +import { TeamRoutes } from "./routes/team" import { MDNS } from "./mdns" import { lazy } from "@/util/lazy" import { initProjectors } from "./projectors" @@ -258,6 +259,7 @@ export namespace Server { .route("/", FileRoutes()) .route("/", EventRoutes()) .route("/mcp", McpRoutes()) + .route("/team", TeamRoutes()) .route("/tui", TuiRoutes()) .post( "/instance/dispose", @@ -596,6 +598,11 @@ export namespace Server { const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + // Reconcile stale teams from previous sessions + import("../team") + .then(({ Team }) => Team.reconcile()) + .catch((e) => log.error("team reconciliation failed", { error: e })) + const shouldPublishMDNS = opts.mdns && server.port && diff --git a/packages/opencode/src/session/inject.ts b/packages/opencode/src/session/inject.ts new file mode 100644 index 000000000000..6046cc42935b --- /dev/null +++ b/packages/opencode/src/session/inject.ts @@ -0,0 +1,100 @@ +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID, MessageID, PartID } from "./schema" +import { Bus } from "../bus" +import { Log } from "../util/log" +import { BusEvent } from "@/bus/bus-event" +import z from "zod" + +const log = Log.create({ service: "session.inject" }) + +export namespace SessionInject { + export const Event = { + MessageInjected: BusEvent.define( + "session.message.injected", + z.object({ + sessionID: SessionID.zod, + from: z.string(), + fromSessionID: SessionID.zod, + }), + ), + } + + /** + * Inject a message into a session from another agent/session. + * This creates a synthetic user message that the prompt loop + * picks up on its next iteration. + * + * For sessions that have already completed their loop, we publish + * an injection event that the prompt loop can listen for to wake up. + */ + export async function send(input: { + sessionID: SessionID + from: string + fromSessionID: SessionID + content: string + teamID?: string + }) { + const id = MessageID.ascending() + log.info("injecting", { + sessionID: input.sessionID, + from: input.from, + }) + + // Resolve the target session's agent from its last user message + const agent = await lastAgent(input.sessionID) + + // Create a synthetic user message tagged as injected + const msg: MessageV2.User = { + id, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent, + model: await lastModel(input.sessionID), + system: undefined, + injected: { + from: input.from, + fromSessionID: input.fromSessionID, + teamID: input.teamID, + }, + } + + await Session.updateMessage(msg) + await Session.updatePart({ + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text: `[Message from @${input.from}]\n\n${input.content}`, + synthetic: true, + } satisfies MessageV2.TextPart) + + Bus.publish(Event.MessageInjected, { + sessionID: input.sessionID, + from: input.from, + fromSessionID: input.fromSessionID, + }) + + log.info("injected", { + sessionID: input.sessionID, + messageID: id, + }) + } + + async function lastModel(sessionID: SessionID) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + // Fallback: use a placeholder that will be resolved by the prompt loop + const { Provider } = await import("../provider/provider") + return Provider.defaultModel() + } + + async function lastAgent(sessionID: SessionID): Promise { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.agent) return item.info.agent + } + return "build" + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 86e43156523b..7905b426d70b 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -374,6 +374,13 @@ export namespace MessageV2 { system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), variant: z.string().optional(), + injected: z + .object({ + from: z.string(), + fromSessionID: z.string(), + teamID: z.string().optional(), + }) + .optional(), }).meta({ ref: "UserMessage", }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b3c34539e77e..92dc23f5f1a3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -34,6 +34,7 @@ import { ulid } from "ulid" import { spawn } from "child_process" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" +import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" @@ -268,9 +269,80 @@ export namespace SessionPrompt { match.abort.abort() delete s[sessionID] await SessionStatus.set(sessionID, { type: "idle" }) + // Auto-disband any teams owned by this session + import("../team") + .then(({ Team }) => Team.disbandBySession(sessionID)) + .catch((e) => log.error("failed to disband teams on cancel", { sessionID, error: e })) return } + /** + * Check if a session is a background team member that should stay alive + * waiting for injected messages (cross-review, challenges, etc.) + */ + async function shouldWaitForMessages(sessionID: SessionID, abort: AbortSignal): Promise { + if (abort.aborted) return false + const session = await Session.get(sessionID) + if (!session.parentID) return false + + // Check if this session is registered as a team member + const { TeamMemberTable } = await import("../team/team.sql") + const { Database, eq } = await import("../storage/db") + const member = Database.use((db) => + db.select().from(TeamMemberTable).where(eq(TeamMemberTable.session_id, sessionID)).get(), + ) + return !!member && member.status === "active" + } + + /** + * Wait for an injected message to arrive in this session. + * Returns true if a message was injected, false if aborted or timed out. + */ + async function waitForInjection(sessionID: SessionID, abort: AbortSignal): Promise { + const config = await Config.get() + const duration = config.team?.member_timeout ?? 300_000 + const { SessionInject } = await import("./inject") + + return new Promise(async (resolve) => { + let resolved = false + + function done(value: boolean) { + if (resolved) return + resolved = true + cleanup() + resolve(value) + } + + const timeout = setTimeout(() => done(false), duration) + + // Subscribe FIRST so no events are missed + const unsub = Bus.subscribe(SessionInject.Event.MessageInjected, (event) => { + if (event.properties.sessionID === sessionID) done(true) + }) + + function onAbort() { + done(false) + } + abort.addEventListener("abort", onAbort) + + function cleanup() { + clearTimeout(timeout) + unsub() + abort.removeEventListener("abort", onAbort) + } + + // THEN check DB for messages injected before subscribe was established + try { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const last = msgs.findLast((m) => m.info.role === "assistant") + const pending = msgs.find((m) => m.info.role === "user" && last && m.info.id > last.info.id) + if (pending) done(true) + } catch (e) { + log.error("failed to check pending messages", { sessionID, error: e }) + } + }) + } + export const LoopInput = z.object({ sessionID: SessionID.zod, resume_existing: z.boolean().optional(), @@ -324,6 +396,17 @@ export namespace SessionPrompt { !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + // Check if this session is a background team member that should stay alive + const shouldWait = await shouldWaitForMessages(sessionID, abort) + if (shouldWait) { + log.info("team member waiting for messages", { sessionID }) + const injected = await waitForInjection(sessionID, abort) + if (injected) continue + // Mark member as completed on normal exit (timeout or no more messages) + import("../team") + .then(({ Team }) => Team.completeMember(sessionID)) + .catch((e) => log.error("failed to complete team member", { sessionID, error: e })) + } log.info("exiting loop", { sessionID }) break } @@ -674,9 +757,11 @@ export namespace SessionPrompt { // Build system prompt, adding structured output instruction if needed const skills = await SystemPrompt.skills(agent) + const mem = SystemPrompt.memory(agent) const system = [ ...(await SystemPrompt.environment(model)), ...(skills ? [skills] : []), + ...(mem ? [mem] : []), ...(await InstructionPrompt.system()), ] const format = lastUser.format ?? { type: "text" } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index ca324652d9dc..e8c985a163d2 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider" import type { Agent } from "@/agent/agent" import { Permission } from "@/permission" import { Skill } from "@/skill" +import { AgentMemory } from "@/agent/memory" export namespace SystemPrompt { export function provider(model: Provider.Model) { @@ -65,4 +66,16 @@ export namespace SystemPrompt { Skill.fmt(list, { verbose: true }), ].join("\n") } + + export function memory(agent: Agent.Info): string | undefined { + if (agent.memory !== "local") return undefined + const mem = AgentMemory.read(agent.name) + if (!mem) return undefined + return [ + ``, + `The following is your persistent memory for this project. Use the agent_memory tool to update it.`, + mem.content, + ``, + ].join("\n") + } } diff --git a/packages/opencode/src/team/index.ts b/packages/opencode/src/team/index.ts new file mode 100644 index 000000000000..7fcd37b09e08 --- /dev/null +++ b/packages/opencode/src/team/index.ts @@ -0,0 +1,302 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq, and, inArray } from "../storage/db" +import { TeamTable, TeamMemberTable, TeamTaskTable } from "./team.sql" +import { TeamID } from "./schema" +import { SessionID } from "../session/schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team" }) + +export namespace Team { + export const Info = z + .object({ + id: TeamID.zod, + sessionID: SessionID.zod, + name: z.string(), + status: z.enum(["active", "disbanded"]), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "Team" }) + export type Info = z.infer + + export const Member = z + .object({ + teamID: TeamID.zod, + sessionID: SessionID.zod, + agent: z.string(), + role: z.enum(["lead", "member"]), + status: z.enum(["active", "completed", "failed", "cancelled"]), + }) + .meta({ ref: "TeamMember" }) + export type Member = z.infer + + export const Event = { + Created: BusEvent.define( + "team.created", + z.object({ + team: Info, + }), + ), + Updated: BusEvent.define( + "team.updated", + z.object({ + team: Info, + }), + ), + Disbanded: BusEvent.define( + "team.disbanded", + z.object({ + teamID: TeamID.zod, + }), + ), + MemberAdded: BusEvent.define( + "team.member.added", + z.object({ + member: Member, + }), + ), + MemberUpdated: BusEvent.define( + "team.member.updated", + z.object({ + member: Member, + }), + ), + } + + function toInfo(row: typeof TeamTable.$inferSelect): Info { + return { + id: row.id, + sessionID: row.session_id, + name: row.name, + status: row.status as Info["status"], + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + function toMember(row: typeof TeamMemberTable.$inferSelect): Member { + return { + teamID: row.team_id, + sessionID: row.session_id, + agent: row.agent, + role: row.role as Member["role"], + status: row.status as Member["status"], + } + } + + export function create(input: { name: string; sessionID: SessionID; agent?: string }): Info { + const id = TeamID.ascending() + const now = Date.now() + const row = Database.transaction((db) => { + const row = db + .insert(TeamTable) + .values({ + id, + session_id: input.sessionID, + name: input.name, + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get() + // Add the lead as first member + db.insert(TeamMemberTable) + .values({ + team_id: id, + session_id: input.sessionID, + agent: input.agent ?? "lead", + role: "lead", + status: "active", + time_created: now, + time_updated: now, + }) + .run() + return row + }) + const info = toInfo(row) + log.info("created", { id: info.id, name: info.name }) + Database.effect(() => Bus.publish(Event.Created, { team: info })) + return info + } + + export function disband(id: TeamID) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamTable).set({ status: "disbanded", time_updated: now }).where(eq(TeamTable.id, id)).run() + db.update(TeamMemberTable) + .set({ status: "cancelled", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.status, "active"))) + .run() + // Cascade: mark in-progress and pending tasks as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamTaskTable.team_id, id), inArray(TeamTaskTable.status, ["in_progress", "pending"]))) + .run() + }) + log.info("disbanded", { id }) + Database.effect(() => Bus.publish(Event.Disbanded, { teamID: id })) + } + + export function get(id: TeamID): Info | undefined { + const row = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.id, id)).get()) + if (!row) return undefined + return toInfo(row) + } + + export function bySession(sessionID: SessionID): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.session_id, sessionID)).all()) + return rows.map(toInfo) + } + + export function members(id: TeamID): Member[] { + const rows = Database.use((db) => db.select().from(TeamMemberTable).where(eq(TeamMemberTable.team_id, id)).all()) + return rows.map(toMember) + } + + export function addMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }): Member { + const team = get(input.teamID) + if (!team) throw new Error(`Team not found: ${input.teamID}`) + if (team.status === "disbanded") throw new Error(`Cannot add member to disbanded team: ${input.teamID}`) + + const now = Date.now() + // Disambiguate + insert in a transaction to prevent TOCTOU races + const row = Database.transaction((db) => { + let agent = input.agent + const existing = db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.role, "member"))) + .all() + const taken = new Set(existing.map((m) => m.agent)) + if (taken.has(agent)) { + let i = 2 + while (taken.has(`${input.agent}-${i}`)) i++ + agent = `${input.agent}-${i}` + } + return db + .insert(TeamMemberTable) + .values({ + team_id: input.teamID, + session_id: input.sessionID, + agent, + role: "member", + status: "active", + time_created: now, + time_updated: now, + }) + .returning() + .get() + }) + const member = toMember(row) + Database.effect(() => Bus.publish(Event.MemberAdded, { member })) + return member + } + + export function updateMember(input: { teamID: TeamID; sessionID: SessionID; status: Member["status"] }) { + const now = Date.now() + Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: input.status, time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run(), + ) + } + + export function findMemberSession(input: { teamID: TeamID; agent: string }): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.agent, input.agent))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + export function leadSession(id: TeamID): Member | undefined { + const row = Database.use((db) => + db + .select() + .from(TeamMemberTable) + .where(and(eq(TeamMemberTable.team_id, id), eq(TeamMemberTable.role, "lead"))) + .get(), + ) + if (!row) return undefined + return toMember(row) + } + + /** Mark a member as completed (normal exit) */ + export function completeMember(sessionID: SessionID) { + const now = Date.now() + const updated = Database.use((db) => + db + .update(TeamMemberTable) + .set({ status: "completed", time_updated: now }) + .where(and(eq(TeamMemberTable.session_id, sessionID), eq(TeamMemberTable.status, "active"))) + .returning() + .get(), + ) + if (!updated) return + const member = toMember(updated) + Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Disband all active teams owned by a session (cleanup on session end) */ + export function disbandBySession(sessionID: SessionID) { + const teams = bySession(sessionID).filter((t) => t.status === "active") + for (const team of teams) { + log.info("auto-disbanding on session end", { teamID: team.id, sessionID }) + disband(team.id) + } + } + + /** Mark a member as failed and cascade failure to owned team tasks */ + export function failMember(input: { teamID: TeamID; sessionID: SessionID; agent: string }) { + const now = Date.now() + Database.transaction((db) => { + db.update(TeamMemberTable) + .set({ status: "failed", time_updated: now }) + .where(and(eq(TeamMemberTable.team_id, input.teamID), eq(TeamMemberTable.session_id, input.sessionID))) + .run() + // Cascade: mark in-progress tasks owned by this agent as failed + db.update(TeamTaskTable) + .set({ status: "failed", time_updated: now }) + .where( + and( + eq(TeamTaskTable.team_id, input.teamID), + eq(TeamTaskTable.owner, input.agent), + eq(TeamTaskTable.status, "in_progress"), + ), + ) + .run() + }) + const member = findMemberSession({ teamID: input.teamID, agent: input.agent }) + if (member) Database.effect(() => Bus.publish(Event.MemberUpdated, { member })) + } + + /** Reconcile stale teams on startup — mark active teams as disbanded */ + export function reconcile() { + const stale = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + for (const row of stale) { + log.info("reconciling stale team", { id: row.id, name: row.name }) + disband(row.id as TeamID) + } + if (stale.length > 0) log.info("reconciled teams", { count: stale.length }) + } + + /** List all active teams (across all sessions) */ + export function active(): Info[] { + const rows = Database.use((db) => db.select().from(TeamTable).where(eq(TeamTable.status, "active")).all()) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/schema.ts b/packages/opencode/src/team/schema.ts new file mode 100644 index 000000000000..67b1b96ad0e9 --- /dev/null +++ b/packages/opencode/src/team/schema.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import z from "zod" +import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" + +export const TeamID = Schema.String.pipe( + Schema.brand("TeamID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team", id)), + zod: Identifier.schema("team").pipe(z.custom>()), + })), +) + +export type TeamID = Schema.Schema.Type + +export const TeamTaskID = Schema.String.pipe( + Schema.brand("TeamTaskID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("team_task", id)), + zod: Identifier.schema("team_task").pipe(z.custom>()), + })), +) + +export type TeamTaskID = Schema.Schema.Type + +export const MemoryID = Schema.String.pipe( + Schema.brand("MemoryID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("memory", id)), + zod: Identifier.schema("memory").pipe(z.custom>()), + })), +) + +export type MemoryID = Schema.Schema.Type diff --git a/packages/opencode/src/team/task.ts b/packages/opencode/src/team/task.ts new file mode 100644 index 000000000000..8025db66da72 --- /dev/null +++ b/packages/opencode/src/team/task.ts @@ -0,0 +1,126 @@ +import z from "zod" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Database, eq } from "../storage/db" +import { TeamTaskTable } from "./team.sql" +import { TeamID, TeamTaskID } from "./schema" +import { Log } from "../util/log" + +const log = Log.create({ service: "team.task" }) + +export namespace TeamTask { + export const Info = z + .object({ + id: TeamTaskID.zod, + teamID: TeamID.zod, + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed", "failed"]), + metadata: z.record(z.string(), z.unknown()).optional(), + time: z.object({ + created: z.number(), + updated: z.number(), + }), + }) + .meta({ ref: "TeamTask" }) + export type Info = z.infer + + export const Event = { + Created: BusEvent.define( + "team.task.created", + z.object({ + task: Info, + }), + ), + Updated: BusEvent.define( + "team.task.updated", + z.object({ + task: Info, + }), + ), + } + + function toInfo(row: typeof TeamTaskTable.$inferSelect): Info { + return { + id: row.id, + teamID: row.team_id, + subject: row.subject, + description: row.description ?? undefined, + owner: row.owner ?? undefined, + status: row.status as Info["status"], + metadata: (row.metadata as Record) ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + }, + } + } + + export function create(input: { + teamID: TeamID + subject: string + description?: string + owner?: string + metadata?: Record + }): Info { + const id = TeamTaskID.ascending() + const now = Date.now() + const row = Database.use((db) => + db + .insert(TeamTaskTable) + .values({ + id, + team_id: input.teamID, + subject: input.subject, + description: input.description, + owner: input.owner, + metadata: input.metadata, + time_created: now, + time_updated: now, + }) + .returning() + .get(), + ) + const info = toInfo(row) + log.info("created", { id: info.id, subject: info.subject }) + Database.effect(() => Bus.publish(Event.Created, { task: info })) + return info + } + + export function update( + id: TeamTaskID, + input: Partial>, + ): Info | undefined { + const now = Date.now() + const values: Record = { time_updated: now } + if (input.status !== undefined) values.status = input.status + if (input.owner !== undefined) values.owner = input.owner + if (input.description !== undefined) values.description = input.description + if (input.metadata !== undefined) values.metadata = input.metadata + + Database.use((db) => + db.update(TeamTaskTable).set(values).where(eq(TeamTaskTable.id, id)).run(), + ) + const updated = get(id) + if (updated) { + Database.effect(() => Bus.publish(Event.Updated, { task: updated })) + } + return updated + } + + export function get(id: TeamTaskID): Info | undefined { + const row = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.id, id)).get(), + ) + if (!row) return undefined + return toInfo(row) + } + + export function list(teamID: TeamID): Info[] { + const rows = Database.use((db) => + db.select().from(TeamTaskTable).where(eq(TeamTaskTable.team_id, teamID)).all(), + ) + return rows.map(toInfo) + } +} diff --git a/packages/opencode/src/team/team.sql.ts b/packages/opencode/src/team/team.sql.ts new file mode 100644 index 000000000000..fe146bfb590e --- /dev/null +++ b/packages/opencode/src/team/team.sql.ts @@ -0,0 +1,85 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core" +import { SessionTable } from "../session/session.sql" +import { ProjectTable } from "../project/project.sql" +import { Timestamps } from "../storage/schema.sql" +import type { TeamID, TeamTaskID, MemoryID } from "./schema" +import type { SessionID } from "../session/schema" +import type { ProjectID } from "../project/schema" + +export const TeamTable = sqliteTable( + "team", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + name: text().notNull(), + status: text({ enum: ["active", "disbanded"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [index("team_session_idx").on(table.session_id)], +) + +export const TeamMemberTable = sqliteTable( + "team_member", + { + id: integer().primaryKey({ autoIncrement: true }), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + role: text({ enum: ["lead", "member"] }).notNull(), + status: text({ enum: ["active", "completed", "failed", "cancelled"] }) + .notNull() + .default("active"), + ...Timestamps, + }, + (table) => [ + index("team_member_team_idx").on(table.team_id), + index("team_member_session_idx").on(table.session_id), + uniqueIndex("team_member_team_agent_idx").on(table.team_id, table.agent), + ], +) + +export const TeamTaskTable = sqliteTable( + "team_task", + { + id: text().$type().primaryKey(), + team_id: text() + .$type() + .notNull() + .references(() => TeamTable.id, { onDelete: "cascade" }), + subject: text().notNull(), + description: text(), + owner: text(), + status: text({ enum: ["pending", "in_progress", "completed", "failed"] }) + .notNull() + .default("pending"), + metadata: text({ mode: "json" }).$type>(), + ...Timestamps, + }, + (table) => [index("team_task_team_idx").on(table.team_id)], +) + +export const AgentMemoryTable = sqliteTable( + "agent_memory", + { + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + agent: text().notNull(), + content: text().notNull(), + ...Timestamps, + }, + (table) => [uniqueIndex("agent_memory_project_agent_idx").on(table.project_id, table.agent)], +) diff --git a/packages/opencode/src/tool/agent-memory.ts b/packages/opencode/src/tool/agent-memory.ts new file mode 100644 index 000000000000..2132d18149df --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.ts @@ -0,0 +1,58 @@ +import z from "zod" +import { Tool } from "./tool" +import { AgentMemory } from "../agent/memory" +import DESCRIPTION from "./agent-memory.txt" + +export const AgentMemoryTool = Tool.define("agent_memory", { + description: DESCRIPTION, + parameters: z.object({ + operation: z.enum(["read", "write", "append"]).describe("The operation to perform"), + content: z.string().optional().describe("Content to write or append (required for write/append)"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "agent_memory", + patterns: [params.operation], + always: ["*"], + metadata: {}, + }) + + if (params.operation === "read") { + const memory = AgentMemory.read(ctx.agent) + if (!memory) { + return { + title: "No memory found", + output: `No stored memory found for agent "${ctx.agent}" in this project. This is a fresh start.`, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, + } + } + return { + title: "Memory loaded", + output: memory.content, + metadata: { + agent: ctx.agent, + updated: memory.time.updated as number | undefined, + }, + } + } + + if (params.operation === "write") { + if (!params.content) throw new Error("content is required for write operation") + AgentMemory.write(ctx.agent, params.content) + return { + title: "Memory updated", + output: `Memory for agent "${ctx.agent}" has been updated.`, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, + } + } + + // append + if (!params.content) throw new Error("content is required for append operation") + AgentMemory.append(ctx.agent, params.content) + return { + title: "Memory appended", + output: `New content appended to memory for agent "${ctx.agent}".`, + metadata: { agent: ctx.agent, updated: undefined as number | undefined }, + } + }, +}) diff --git a/packages/opencode/src/tool/agent-memory.txt b/packages/opencode/src/tool/agent-memory.txt new file mode 100644 index 000000000000..ea73f0c3484f --- /dev/null +++ b/packages/opencode/src/tool/agent-memory.txt @@ -0,0 +1,11 @@ +Read or write persistent per-agent memory for this project. + +Agent memory persists across sessions — use it to store learned patterns, conventions, recurring issues, and domain knowledge specific to this project. + +Operations: +- **read**: Read your stored memory for this project. Call this at the start of a task to recall past learnings. +- **write**: Replace your entire memory content. Use this to store a curated set of learnings. +- **append**: Add new content to existing memory without replacing it. Use this to accumulate learnings incrementally. + +Memory is scoped to (agent, project) — each agent has independent memory per project. +Keep memory concise and actionable. Focus on patterns and conventions, not raw data. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6381fcfbc0c2..8c4a4ff1c32b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -12,6 +12,11 @@ import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" import { SkillTool } from "./skill" +import { TeamCreateTool } from "./team-create" +import { TeamDeleteTool } from "./team-delete" +import { TeamTaskTool } from "./team-task" +import { SendMessageTool } from "./send-message" +import { AgentMemoryTool } from "./agent-memory" import type { Agent } from "../agent/agent" import { Tool } from "./tool" import { Config } from "../config/config" @@ -129,6 +134,11 @@ export namespace ToolRegistry { CodeSearchTool, SkillTool, ApplyPatchTool, + TeamCreateTool, + TeamDeleteTool, + TeamTaskTool, + SendMessageTool, + AgentMemoryTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(cfg.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []), diff --git a/packages/opencode/src/tool/send-message.ts b/packages/opencode/src/tool/send-message.ts new file mode 100644 index 000000000000..39478e3f9d0f --- /dev/null +++ b/packages/opencode/src/tool/send-message.ts @@ -0,0 +1,63 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { SessionInject } from "../session/inject" +import DESCRIPTION from "./send-message.txt" + +export const SendMessageTool = Tool.define("send_message", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID"), + recipient: z.string().default("lead").describe("Agent name to send to, or 'lead' for the team lead"), + content: z.string().describe("The message content"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "send_message", + patterns: [params.recipient], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(params.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${params.team_id}`) + + // Validate sender is a member of this team + const members = Team.members(teamID) + const sender = members.find((m) => m.sessionID === ctx.sessionID) + if (!sender) throw new Error(`You are not a member of team "${team.name}"`) + + // Resolve recipient session + let target: Team.Member | undefined + if (params.recipient === "lead") { + target = Team.leadSession(teamID) + } else { + target = Team.findMemberSession({ + teamID, + agent: params.recipient, + }) + } + + if (!target) throw new Error(`Recipient "${params.recipient}" not found in team "${team.name}"`) + + await SessionInject.send({ + sessionID: target.sessionID, + from: ctx.agent, + fromSessionID: ctx.sessionID, + content: params.content, + teamID: params.team_id, + }) + + return { + title: `Message sent to ${params.recipient}`, + output: `Message delivered to @${params.recipient} in team "${team.name}".`, + metadata: { + teamID: params.team_id, + recipient: params.recipient, + }, + } + }, +}) diff --git a/packages/opencode/src/tool/send-message.txt b/packages/opencode/src/tool/send-message.txt new file mode 100644 index 000000000000..b7d600308193 --- /dev/null +++ b/packages/opencode/src/tool/send-message.txt @@ -0,0 +1,13 @@ +Send a message to another agent in your team. + +This enables inter-agent communication within a team: +- **Member → Lead**: Send findings, status updates, or responses to the team lead +- **Lead → Member**: Route challenges, requests for elaboration, or cross-review findings to specific agents + +Usage: +- You must be part of a team to use this tool +- Specify the team_id and either a specific agent name or "lead" as the recipient +- The message content should be clear and actionable +- Messages are delivered asynchronously — the recipient processes them on their next turn + +When receiving messages, respond substantively. For cross-review challenges, provide evidence from the spec or codebase. \ No newline at end of file diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f2a83507fa6f..8fd5244c001a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -11,6 +11,16 @@ import { iife } from "@/util/iife" import { defer } from "@/util/defer" import { Config } from "../config/config" import { Permission } from "@/permission" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import { Instance } from "@/project/instance" +import { SessionInject } from "@/session/inject" +import { Log } from "@/util/log" + +const log = Log.create({ service: "tool.task" }) + +/** Tracks active background agent count (process-wide) for concurrency limiting */ +let running = 0 const parameters = z.object({ description: z.string().describe("A short (3-5 words) description of the task"), @@ -23,6 +33,18 @@ const parameters = z.object({ ) .optional(), command: z.string().describe("The command that triggered this task").optional(), + background: z + .boolean() + .describe( + "If true, the agent runs in the background and returns immediately with the task_id. Use this with team_id for parallel multi-agent workflows.", + ) + .optional(), + team_id: z + .string() + .describe( + "The team ID to register this agent as a member. Required when background is true for team-based workflows.", + ) + .optional(), }) export const TaskTool = Tool.define("task", async (ctx) => { @@ -65,6 +87,37 @@ export const TaskTool = Tool.define("task", async (ctx) => { const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") + // Build permission overrides for child session + const childPermissions = [ + { + permission: "todowrite" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ...(hasTaskPermission + ? [] + : [ + { + permission: "task" as const, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(config.experimental?.primary_tools?.map((t) => ({ + pattern: "*" as const, + action: "allow" as const, + permission: t, + })) ?? []), + ] + + // For team members, grant team communication permissions + if (params.team_id) { + childPermissions.push( + { permission: "send_message", pattern: "*", action: "allow" }, + { permission: "team_task", pattern: "*", action: "allow" }, + ) + } + const session = await iife(async () => { if (params.task_id) { const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) @@ -74,29 +127,20 @@ export const TaskTool = Tool.define("task", async (ctx) => { return await Session.create({ parentID: ctx.sessionID, title: params.description + ` (@${agent.name} subagent)`, - permission: [ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - ...(hasTaskPermission - ? [] - : [ - { - permission: "task" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(config.experimental?.primary_tools?.map((t) => ({ - pattern: "*", - action: "allow" as const, - permission: t, - })) ?? []), - ], + permission: childPermissions, }) }) + + // Register as team member if team_id provided + if (params.team_id) { + const teamID = TeamID.make(params.team_id) + Team.addMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + } + const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") @@ -110,19 +154,15 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + background: params.background, + teamID: params.team_id, }, }) const messageID = MessageID.ascending() - - function cancel() { - SessionPrompt.cancel(session.id) - } - ctx.abort.addEventListener("abort", cancel) - using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const result = await SessionPrompt.prompt({ + const promptInput = { messageID, sessionID: session.id, model: { @@ -136,7 +176,94 @@ export const TaskTool = Tool.define("task", async (ctx) => { ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])), }, parts: promptParts, - }) + } + + // Background execution — launch and return immediately + if (params.background) { + const limit = config.team?.max_agents ?? 10 + if (running >= limit) { + throw new Error( + `Max concurrent background agents reached (${limit}). Wait for existing agents to complete or increase team.max_agents in config.`, + ) + } + + const teamID = params.team_id ? TeamID.make(params.team_id) : undefined + const bound = Instance.bind(() => { + SessionPrompt.prompt(promptInput) + .catch(async (err) => { + log.error("background agent failed", { + sessionID: session.id, + agent: agent.name, + error: err, + }) + if (teamID) { + Team.failMember({ + teamID, + sessionID: session.id, + agent: agent.name, + }) + // Notify the lead about the failure + const lead = Team.leadSession(teamID) + if (lead) { + await SessionInject.send({ + sessionID: lead.sessionID, + from: agent.name, + fromSessionID: session.id, + content: `[AGENT FAILURE] @${agent.name} crashed with error: ${err instanceof Error ? err.message : String(err)}`, + teamID: params.team_id, + }).catch((e) => log.error("failed to notify lead of agent failure", { error: e })) + } + } + }) + .finally(() => { + running-- + // Clean up the abort listener when the agent completes + ctx.abort.removeEventListener("abort", cancel) + }) + }) + // Wire up abort before launching so cancellation works immediately + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + + running++ + try { + bound() + } catch (e) { + running-- + ctx.abort.removeEventListener("abort", cancel) + throw e + } + + return { + title: `${params.description} (background)`, + metadata: { + sessionId: session.id, + model, + background: true, + teamID: params.team_id, + }, + output: [ + `task_id: ${session.id} (background agent launched)`, + "", + `Agent @${agent.name} is now running in the background.`, + params.team_id ? `Registered as member of team ${params.team_id}.` : "", + "The agent will send messages via SendMessage when it has findings.", + ] + .filter(Boolean) + .join("\n"), + } + } + + // Synchronous execution — existing behavior + function cancel() { + SessionPrompt.cancel(session.id) + } + ctx.abort.addEventListener("abort", cancel) + using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) + + const result = await SessionPrompt.prompt(promptInput) const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" @@ -153,6 +280,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { metadata: { sessionId: session.id, model, + background: false as boolean, + teamID: undefined as string | undefined, }, output, } diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index 585cce8f9d0a..8231bbf5bddc 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -23,6 +23,11 @@ Usage notes: 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +Team workflows: +7. For multi-agent team workflows, first create a team with team_create, then spawn agents with `background: true` and `team_id` set. Background agents run concurrently and communicate via send_message. +8. When `background: true`, the task returns immediately with the task_id. The agent runs in the background and sends results via the send_message tool. +9. Use `team_id` to register the agent as a team member. Team members can use send_message and team_task tools to coordinate. + Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): diff --git a/packages/opencode/src/tool/team-create.ts b/packages/opencode/src/tool/team-create.ts new file mode 100644 index 000000000000..6d4c6bcc3a5c --- /dev/null +++ b/packages/opencode/src/tool/team-create.ts @@ -0,0 +1,39 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import DESCRIPTION from "./team-create.txt" + +export const TeamCreateTool = Tool.define("team_create", { + description: DESCRIPTION, + parameters: z.object({ + name: z.string().describe("A descriptive name for the team (e.g., 'spec-review-auth-service')"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_create", + patterns: [params.name], + always: ["*"], + metadata: { name: params.name }, + }) + + const team = Team.create({ + name: params.name, + sessionID: ctx.sessionID, + agent: ctx.agent, + }) + + return { + title: `Team created: ${params.name}`, + output: JSON.stringify( + { + team_id: team.id, + name: team.name, + status: team.status, + }, + null, + 2, + ), + metadata: { team }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-create.txt b/packages/opencode/src/tool/team-create.txt new file mode 100644 index 000000000000..bd27121911d4 --- /dev/null +++ b/packages/opencode/src/tool/team-create.txt @@ -0,0 +1,11 @@ +Create a named agent team for coordinating multiple specialist sub-agents. + +Teams enable multi-agent workflows where a lead orchestrator spawns specialist agents that work in parallel and communicate findings back through the lead. + +Usage: +- Create a team before spawning background agents that need to coordinate +- The team name should be descriptive (e.g., "spec-review-auth-service") +- After creating a team, use the Task tool with `background: true` and `team_id` to spawn team members +- Use TeamDelete to disband the team when all work is complete + +Returns the team ID which must be passed to subsequent team operations (TaskCreate, SendMessage, TeamDelete). \ No newline at end of file diff --git a/packages/opencode/src/tool/team-delete.ts b/packages/opencode/src/tool/team-delete.ts new file mode 100644 index 000000000000..da71c844db9d --- /dev/null +++ b/packages/opencode/src/tool/team-delete.ts @@ -0,0 +1,33 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamID } from "../team/schema" +import DESCRIPTION from "./team-delete.txt" + +export const TeamDeleteTool = Tool.define("team_delete", { + description: DESCRIPTION, + parameters: z.object({ + team_id: z.string().describe("The team ID returned by TeamCreate"), + }), + async execute(params, ctx) { + await ctx.ask({ + permission: "team_delete", + patterns: [params.team_id], + always: ["*"], + metadata: {}, + }) + + const id = TeamID.make(params.team_id) + const team = Team.get(id) + if (!team) throw new Error(`Team not found: ${params.team_id}`) + if (team.status === "disbanded") throw new Error(`Team already disbanded: ${params.team_id}`) + + Team.disband(id) + + return { + title: `Team disbanded: ${team.name}`, + output: `Team "${team.name}" (${id}) has been disbanded. All active members marked as cancelled.`, + metadata: { teamID: id }, + } + }, +}) diff --git a/packages/opencode/src/tool/team-delete.txt b/packages/opencode/src/tool/team-delete.txt new file mode 100644 index 000000000000..281241c0405d --- /dev/null +++ b/packages/opencode/src/tool/team-delete.txt @@ -0,0 +1,5 @@ +Disband a team and mark all active members as cancelled. + +Call this after all team work is done. Agents persist any learnings via their memory — they do not need to stay alive for context retention. + +This does NOT terminate running background agents immediately — it marks the team as disbanded and updates member statuses. Background agents will complete their current work naturally. \ No newline at end of file diff --git a/packages/opencode/src/tool/team-task.ts b/packages/opencode/src/tool/team-task.ts new file mode 100644 index 000000000000..c947f4ed47f6 --- /dev/null +++ b/packages/opencode/src/tool/team-task.ts @@ -0,0 +1,89 @@ +import z from "zod" +import { Tool } from "./tool" +import { Team } from "../team" +import { TeamTask } from "../team/task" +import { TeamID, TeamTaskID } from "../team/schema" +import DESCRIPTION from "./team-task.txt" + +const params = z.object({ + operation: z.enum(["create", "update", "get", "list"]).describe("The operation to perform"), + team_id: z.string().describe("The team ID"), + task_id: z.string().optional().describe("The task ID (required for get/update)"), + subject: z.string().optional().describe("Task subject (required for create)"), + description: z.string().optional().describe("Task description"), + owner: z.string().optional().describe("Agent name that owns this task"), + status: z.enum(["pending", "in_progress", "completed", "failed"]).optional().describe("Task status (for update)"), + metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary metadata"), +}) + +export const TeamTaskTool = Tool.define("team_task", { + description: DESCRIPTION, + parameters: params, + async execute(args, ctx) { + await ctx.ask({ + permission: "team_task", + patterns: [args.operation], + always: ["*"], + metadata: {}, + }) + + const teamID = TeamID.make(args.team_id) + const team = Team.get(teamID) + if (!team) throw new Error(`Team not found: ${args.team_id}`) + if (team.status === "disbanded") throw new Error(`Team has been disbanded: ${args.team_id}`) + + type Meta = { task?: TeamTask.Info; tasks?: TeamTask.Info[] } + + if (args.operation === "create") { + if (!args.subject) throw new Error("subject is required for create operation") + const task = TeamTask.create({ + teamID, + subject: args.subject, + description: args.description, + owner: args.owner, + metadata: args.metadata, + }) + return { + title: `Task created: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task } as Meta, + } + } + + if (args.operation === "update") { + if (!args.task_id) throw new Error("task_id is required for update operation") + const id = TeamTaskID.make(args.task_id) + const task = TeamTask.update(id, { + status: args.status, + owner: args.owner, + description: args.description, + metadata: args.metadata, + }) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task updated: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task } as Meta, + } + } + + if (args.operation === "get") { + if (!args.task_id) throw new Error("task_id is required for get operation") + const task = TeamTask.get(TeamTaskID.make(args.task_id)) + if (!task) throw new Error(`Task not found: ${args.task_id}`) + return { + title: `Task: ${task.subject}`, + output: JSON.stringify(task, null, 2), + metadata: { task } as Meta, + } + } + + // list + const tasks = TeamTask.list(teamID) + return { + title: `${tasks.length} tasks`, + output: JSON.stringify(tasks, null, 2), + metadata: { tasks } as Meta, + } + }, +}) diff --git a/packages/opencode/src/tool/team-task.txt b/packages/opencode/src/tool/team-task.txt new file mode 100644 index 000000000000..e6256d1a6d2b --- /dev/null +++ b/packages/opencode/src/tool/team-task.txt @@ -0,0 +1,11 @@ +Manage tasks on a team's shared task board. Team tasks are visible to all team members and provide coordination state. + +Operations: +- **create**: Create a new task on the team board. Use this to assign work items to specialist agents. +- **update**: Update a task's status, owner, or description. Agents should update their tasks to "in_progress" when starting and "completed" when done. +- **get**: Read a specific task by ID. +- **list**: List all tasks for a team. + +Task statuses: pending, in_progress, completed, failed. + +Tasks are distinct from the Todo tool — Todos track an individual agent's work plan, while Team Tasks coordinate across multiple agents in a team. \ No newline at end of file diff --git a/packages/opencode/test/agent/memory.test.ts b/packages/opencode/test/agent/memory.test.ts new file mode 100644 index 000000000000..f6026308be2d --- /dev/null +++ b/packages/opencode/test/agent/memory.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { AgentMemory } from "../../src/agent/memory" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("AgentMemory", () => { + test("read returns undefined when no memory exists", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const result = AgentMemory.read("nonexistent-agent") + expect(result).toBeUndefined() + }, + }) + }) + + test("write creates new memory entry", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("test-writer", "some learned patterns") + const result = AgentMemory.read("test-writer") + + expect(result).toBeDefined() + expect(result!.agent).toBe("test-writer") + expect(result!.content).toBe("some learned patterns") + }, + }) + }) + + test("write updates existing memory", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("updater", "version 1") + AgentMemory.write("updater", "version 2") + + const result = AgentMemory.read("updater") + expect(result!.content).toBe("version 2") + }, + }) + }) + + test("write emits Updated event", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + let received: { agent: string; projectID: string } | undefined + const unsub = Bus.subscribe(AgentMemory.Event.Updated, (e) => { + received = e.properties + }) + AgentMemory.write("evt-agent", "data") + await new Promise((r) => setTimeout(r, 50)) + unsub() + + expect(received).toBeDefined() + expect(received!.agent).toBe("evt-agent") + }, + }) + }) + + test("append creates memory if none exists", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.append("appender-new", "first line") + const result = AgentMemory.read("appender-new") + expect(result!.content).toBe("first line") + }, + }) + }) + + test("append adds to existing memory", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("appender", "line 1") + AgentMemory.append("appender", "line 2") + + const result = AgentMemory.read("appender") + expect(result!.content).toBe("line 1\n\nline 2") + }, + }) + }) + + test("write truncates content exceeding 100KB", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const big = "x".repeat(200_000) + AgentMemory.write("big-agent", big) + + const result = AgentMemory.read("big-agent") + expect(result).toBeDefined() + expect(Buffer.byteLength(result!.content, "utf8")).toBeLessThanOrEqual(102_400) + }, + }) + }) + + test("memory is scoped per agent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + AgentMemory.write("agent-a", "data for a") + AgentMemory.write("agent-b", "data for b") + + expect(AgentMemory.read("agent-a")!.content).toBe("data for a") + expect(AgentMemory.read("agent-b")!.content).toBe("data for b") + }, + }) + }) +}) diff --git a/packages/opencode/test/team/task.test.ts b/packages/opencode/test/team/task.test.ts new file mode 100644 index 000000000000..e535526a3f5b --- /dev/null +++ b/packages/opencode/test/team/task.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Team } from "../../src/team" +import { TeamTask } from "../../src/team/task" +import { TeamTaskID } from "../../src/team/schema" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("TeamTask", () => { + test("create returns a pending task", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "task-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "review spec" }) + + expect(task.subject).toBe("review spec") + expect(task.status).toBe("pending") + expect(task.teamID).toBe(team.id) + await Session.remove(session.id) + }, + }) + }) + + test("create with owner and metadata", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "meta-team", sessionID: session.id }) + const task = TeamTask.create({ + teamID: team.id, + subject: "check", + owner: "clarity-reviewer", + description: "Review for clarity", + metadata: { priority: "high" }, + }) + + expect(task.owner).toBe("clarity-reviewer") + expect(task.description).toBe("Review for clarity") + expect(task.metadata).toEqual({ priority: "high" }) + await Session.remove(session.id) + }, + }) + }) + + test("update changes status", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "update-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "updatable" }) + + const updated = TeamTask.update(task.id, { status: "in_progress" }) + expect(updated).toBeDefined() + expect(updated!.status).toBe("in_progress") + await Session.remove(session.id) + }, + }) + }) + + test("update returns undefined for missing task", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const result = TeamTask.update(TeamTaskID.make("ttk_nope"), { status: "completed" }) + expect(result).toBeUndefined() + }, + }) + }) + + test("get returns task by id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "get-task-team", sessionID: session.id }) + const task = TeamTask.create({ teamID: team.id, subject: "gettable" }) + + const found = TeamTask.get(task.id) + expect(found).toBeDefined() + expect(found!.subject).toBe("gettable") + await Session.remove(session.id) + }, + }) + }) + + test("list returns all tasks for a team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "list-team", sessionID: session.id }) + TeamTask.create({ teamID: team.id, subject: "task-a" }) + TeamTask.create({ teamID: team.id, subject: "task-b" }) + TeamTask.create({ teamID: team.id, subject: "task-c" }) + + const tasks = TeamTask.list(team.id) + expect(tasks.length).toBe(3) + expect(tasks.map((t) => t.subject).sort()).toEqual(["task-a", "task-b", "task-c"]) + await Session.remove(session.id) + }, + }) + }) + + test("disband cascades pending and in_progress tasks to failed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "cascade-team", sessionID: session.id }) + const pending = TeamTask.create({ teamID: team.id, subject: "pending-task" }) + const progress = TeamTask.create({ teamID: team.id, subject: "progress-task" }) + TeamTask.update(progress.id, { status: "in_progress" }) + const done = TeamTask.create({ teamID: team.id, subject: "done-task" }) + TeamTask.update(done.id, { status: "completed" }) + + Team.disband(team.id) + + expect(TeamTask.get(pending.id)!.status).toBe("failed") + expect(TeamTask.get(progress.id)!.status).toBe("failed") + expect(TeamTask.get(done.id)!.status).toBe("completed") + await Session.remove(session.id) + }, + }) + }) + + test("failMember cascades in-progress tasks owned by agent", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "fail-cascade", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + const t1 = TeamTask.create({ teamID: team.id, subject: "owned", owner: "worker" }) + TeamTask.update(t1.id, { status: "in_progress" }) + const t2 = TeamTask.create({ teamID: team.id, subject: "other", owner: "someone-else" }) + TeamTask.update(t2.id, { status: "in_progress" }) + + Team.failMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + expect(TeamTask.get(t1.id)!.status).toBe("failed") + expect(TeamTask.get(t2.id)!.status).toBe("in_progress") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) +}) diff --git a/packages/opencode/test/team/team.test.ts b/packages/opencode/test/team/team.test.ts new file mode 100644 index 000000000000..eae8024d0d16 --- /dev/null +++ b/packages/opencode/test/team/team.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Team } from "../../src/team" +import { TeamID } from "../../src/team/schema" +import { Bus } from "../../src/bus" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" + +const root = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("Team", () => { + test("create returns active team with lead member", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "test-team", sessionID: session.id }) + expect(team.name).toBe("test-team") + expect(team.status).toBe("active") + expect(team.sessionID).toBe(session.id) + + const lead = Team.leadSession(team.id) + expect(lead).toBeDefined() + expect(lead!.role).toBe("lead") + expect(lead!.agent).toBe("lead") + expect(lead!.status).toBe("active") + await Session.remove(session.id) + }, + }) + }) + + test("create emits Created event", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + let received: Team.Info | undefined + const unsub = Bus.subscribe(Team.Event.Created, (e) => { + received = e.properties.team + }) + const team = Team.create({ name: "evt-team", sessionID: session.id }) + await new Promise((r) => setTimeout(r, 50)) + unsub() + expect(received).toBeDefined() + expect(received!.id).toBe(team.id) + await Session.remove(session.id) + }, + }) + }) + + test("get returns team by id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await Session.create({}) + const team = Team.create({ name: "get-team", sessionID: session.id }) + const found = Team.get(team.id) + expect(found).toBeDefined() + expect(found!.id).toBe(team.id) + expect(found!.name).toBe("get-team") + await Session.remove(session.id) + }, + }) + }) + + test("get returns undefined for missing team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const found = Team.get(TeamID.make("tem_nonexistent")) + expect(found).toBeUndefined() + }, + }) + }) + + test("addMember adds a member to the team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "member-team", sessionID: s1.id }) + const member = Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "reviewer" }) + + expect(member.agent).toBe("reviewer") + expect(member.role).toBe("member") + expect(member.status).toBe("active") + + const all = Team.members(team.id) + expect(all.length).toBe(2) // lead + member + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("addMember disambiguates duplicate agent names", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const s3 = await Session.create({}) + const s4 = await Session.create({}) + const team = Team.create({ name: "dup-team", sessionID: s1.id }) + const m1 = Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "general" }) + const m2 = Team.addMember({ teamID: team.id, sessionID: s3.id, agent: "general" }) + const m3 = Team.addMember({ teamID: team.id, sessionID: s4.id, agent: "general" }) + + expect(m1.agent).toBe("general") + expect(m2.agent).toBe("general-2") + expect(m3.agent).toBe("general-3") + await Session.remove(s1.id) + await Session.remove(s2.id) + await Session.remove(s3.id) + await Session.remove(s4.id) + }, + }) + }) + + test("addMember rejects disbanded team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "dead-team", sessionID: s1.id }) + Team.disband(team.id) + expect(() => Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "x" })).toThrow( + "Cannot add member to disbanded team", + ) + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("addMember rejects nonexistent team", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + expect(() => Team.addMember({ teamID: TeamID.make("tem_nope"), sessionID: s1.id, agent: "x" })).toThrow( + "Team not found", + ) + await Session.remove(s1.id) + }, + }) + }) + + test("disband sets team disbanded and members cancelled", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "disband-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + Team.disband(team.id) + + const found = Team.get(team.id) + expect(found!.status).toBe("disbanded") + + const all = Team.members(team.id) + for (const m of all) { + expect(m.status).toBe("cancelled") + } + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("completeMember marks active member as completed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "complete-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "worker" }) + + Team.completeMember(s2.id) + + const member = Team.findMemberSession({ teamID: team.id, agent: "worker" }) + expect(member!.status).toBe("completed") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("failMember marks member as failed", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "fail-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "crasher" }) + + Team.failMember({ teamID: team.id, sessionID: s2.id, agent: "crasher" }) + + const member = Team.findMemberSession({ teamID: team.id, agent: "crasher" }) + expect(member!.status).toBe("failed") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("disbandBySession disbands all active teams for a session", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const t1 = Team.create({ name: "dbs-1", sessionID: s1.id }) + const t2 = Team.create({ name: "dbs-2", sessionID: s1.id }) + + Team.disbandBySession(s1.id) + + expect(Team.get(t1.id)!.status).toBe("disbanded") + expect(Team.get(t2.id)!.status).toBe("disbanded") + await Session.remove(s1.id) + }, + }) + }) + + test("reconcile disbands all stale active teams", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const t1 = Team.create({ name: "stale-1", sessionID: s1.id }) + const t2 = Team.create({ name: "stale-2", sessionID: s2.id }) + + Team.reconcile() + + expect(Team.get(t1.id)!.status).toBe("disbanded") + expect(Team.get(t2.id)!.status).toBe("disbanded") + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) + + test("findMemberSession returns member by agent name", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const s1 = await Session.create({}) + const s2 = await Session.create({}) + const team = Team.create({ name: "find-team", sessionID: s1.id }) + Team.addMember({ teamID: team.id, sessionID: s2.id, agent: "finder" }) + + const found = Team.findMemberSession({ teamID: team.id, agent: "finder" }) + expect(found).toBeDefined() + expect(found!.sessionID).toBe(s2.id) + + const missing = Team.findMemberSession({ teamID: team.id, agent: "nonexistent" }) + expect(missing).toBeUndefined() + await Session.remove(s1.id) + await Session.remove(s2.id) + }, + }) + }) +})