diff --git a/extensions/messages/README.md b/extensions/messages/README.md index 1d7e8f7..911f828 100644 --- a/extensions/messages/README.md +++ b/extensions/messages/README.md @@ -8,6 +8,7 @@ They use world files and world git. They do not use native filesystem or process `branch` requires an absolute `repo` path and a prompt. It forks a listed tree root and writes `board.started.by` in the same transaction. The coordinator definition supplies the branch configuration. +The branch start event reports the coordinator's model, not the inherited root model. Worktree isolation requires the later workspaces port. The default is a shared checkout. The tool admits the prompt as a reply-enabled message. @@ -22,6 +23,8 @@ A branch reports to its starter when its work ends. While a child, landing, or reply remains pending, its final answer becomes a passive note. One-way updates to the starter also become passive notes. The starter can answer a report without an endless exchange of routed answers. +The starter's final answer reaches the branch without a faulted routing task. +A static test checks free runtime identifiers in every extension module, including function bodies. The grant chain contains fork ancestry before starter ancestry. Each conversation occurs once. A root fork that @ultimate starts includes `[branch, root, ultimate]`. diff --git a/extensions/messages/branch.ts b/extensions/messages/branch.ts index c81e65e..1ca4af2 100644 --- a/extensions/messages/branch.ts +++ b/extensions/messages/branch.ts @@ -22,7 +22,7 @@ export const branchTool = (host: any) => defineTool({ const coordinator = definitions.find((definition: any) => definition.name === 'coordinator') if (!coordinator) throw new Error('Missing coordinator definition') const rootAgent = await (await host.harness().conversation(root, ctx))!.agent(ctx) - const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx) + const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, model: coordinator.runs.model, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx) const existing = await workspaces?.get(branch.id, ctx) const cwd = existing ? (await (await host.harness().conversation(branch.id, ctx)).agent(ctx)).cwd : input.repo const ancestors = workspaces ? (await workspaces.chain(branch.id, ctx)).slice(1) : [] diff --git a/extensions/messages/route.ts b/extensions/messages/route.ts index f61cf58..ff52ebb 100644 --- a/extensions/messages/route.ts +++ b/extensions/messages/route.ts @@ -14,6 +14,7 @@ import type { import type { Host as ExtensionHost } from '../../host/js/extensions.ts' import { Subagents } from '../subagents/api.ts' +import { ConversationsApi } from '../conversations/api.ts' import { Messages } from './api.ts' import { terminal } from './log.ts' diff --git a/host/js/messages-test-bundle.js b/host/js/messages-test-bundle.js index 2609ffc..229ba59 100644 --- a/host/js/messages-test-bundle.js +++ b/host/js/messages-test-bundle.js @@ -31696,6 +31696,8 @@ var until2 = async (test) => { var models = createModels(); var faux = fauxProvider({ tokensPerSecond: 1e5 }); models.setProvider(faux.provider); +var template = models.getModels("faux")[0]; +models.setProvider({ ...faux.provider, getModels: () => [template, { ...template, id: "coordinator" }] }); var registry = createRegistry(); var storage = await openStorage(globalThis.storagePath); var harness = await Harness.open(storage, { models, registry, settings: { get extensions() { @@ -31814,12 +31816,21 @@ try { assert((await entries2(branch)).some((e) => e.kind === "board.message" && e.data.text === "first task"), "The first message did not reach the branch"); assert((await harness.commit((tx) => tx.conversation(branch.id), BACKGROUND_CONTEXT)).parent.conversationId === root.id, "Branch must fork its root"); assert((await harness.snapshot(Started2, branch.id, BACKGROUND_CONTEXT)).by === primary.id, "Missing starter"); + const starts = (await globalThis.testHost.eventLog.recent()).filter((event) => event.name === "subagents.started" && event.data.id === branch.id); + assert(starts.length === 1 && starts[0].data.model === "faux/coordinator", `Effective branch model: ${JSON.stringify(starts)}`); + assert((await branch.agent(BACKGROUND_CONTEXT)).model.modelId === "coordinator", "The branch must use its advertised model"); assert(JSON.stringify(await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), "Fork and starter grant ancestry"); const before = (await entries2(primary)).length; await (await branch.submit({ type: "input", content: "later work" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT); await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.text === "Answer: later work" && e.data.delivery !== "note")); await harness.waitForIdle(BACKGROUND_CONTEXT); assert((await entries2(primary)).length > before, "An idle branch must report"); + assert((await entries2(branch)).some((entry2) => entry2.kind === "board.message" && entry2.data.from === primary.id && entry2.data.routed === true), "The final answer must route back to the branch"); + await until2(async () => { + const routeTasks = (await harness.commit((tx) => tx.scanTasks({ kind: "board.route" }, 1e3), BACKGROUND_CONTEXT)).items; + assert(routeTasks.every((task) => task.state.status !== "terminal" || task.state.outcome.status === "completed"), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`); + return routeTasks.length > 0 && routeTasks.every((task) => task.state.status === "terminal"); + }); await tool(branch, "subagent", { action: "start", agent: "scout", task: "HOLD", background: true }); await until2(() => holding.size > 0); await until2(async () => (await entries2(primary)).some((e) => e.kind === "board.message" && e.data.from === branch.id && e.data.delivery === "note")); diff --git a/host/js/messages-test.ts b/host/js/messages-test.ts index 3e06cc5..c8995a1 100644 --- a/host/js/messages-test.ts +++ b/host/js/messages-test.ts @@ -10,6 +10,8 @@ import {openStorage} from './sqlite'; const assert = (value: any, message: string) => {if (!value) throw new Error(message)}; const until = async (test: () => any) => {const deadline = Date.now() + 15000; while (!await test()) {if (Date.now() > deadline) throw new Error('Messages deadline'); await new Promise(r => setTimeout(r, 0))}}; const models = createModels(), faux = fauxProvider({tokensPerSecond: 100000}); models.setProvider(faux.provider); +const template = models.getModels('faux')[0]; +models.setProvider({...faux.provider, getModels: () => [template, {...template, id: 'coordinator'}]}); const registry = createRegistry(), storage = await openStorage(globalThis.storagePath); const harness = await Harness.open(storage, {models, registry, settings: {get extensions() {return registry.snapshot().installed()}}}, context); const root = await harness.root(context, {agent: {model: {provider: 'faux', modelId: 'faux-1'}, cwd: '/src/sausage'}}); @@ -99,12 +101,21 @@ try { assert((await entries(branch)).some((e: any) => e.kind === 'board.message' && e.data.text === 'first task'), 'The first message did not reach the branch'); assert((await harness.commit((tx: any) => tx.conversation(branch.id), context)).parent.conversationId === root.id, 'Branch must fork its root'); assert((await harness.snapshot(Started, branch.id, context)).by === primary.id, 'Missing starter'); + const starts = (await globalThis.testHost.eventLog.recent()).filter((event: any) => event.name === 'subagents.started' && event.data.id === branch.id); + assert(starts.length === 1 && starts[0].data.model === 'faux/coordinator', `Effective branch model: ${JSON.stringify(starts)}`); + assert((await branch.agent(context)).model.modelId === 'coordinator', 'The branch must use its advertised model'); assert(JSON.stringify(await conversationChains(harness, context)(branch.id)) === JSON.stringify([String(branch.id), String(root.id), String(primary.id)]), 'Fork and starter grant ancestry'); const before = (await entries(primary)).length; await (await branch.submit({type: 'input', content: 'later work'}, context)).wait(context); await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.text === 'Answer: later work' && e.data.delivery !== 'note')); await harness.waitForIdle(context); assert((await entries(primary)).length > before, 'An idle branch must report'); + assert((await entries(branch)).some((entry: any) => entry.kind === 'board.message' && entry.data.from === primary.id && entry.data.routed === true), 'The final answer must route back to the branch'); + await until(async () => { + const routeTasks = (await harness.commit((tx: any) => tx.scanTasks({kind: 'board.route'}, 1000), context)).items; + assert(routeTasks.every((task: any) => task.state.status !== 'terminal' || task.state.outcome.status === 'completed'), `Final-answer routing fault: ${JSON.stringify(routeTasks)}`); + return routeTasks.length > 0 && routeTasks.every((task: any) => task.state.status === 'terminal'); + }); await tool(branch, 'subagent', {action: 'start', agent: 'scout', task: 'HOLD', background: true}); await until(() => holding.size > 0); await until(async () => (await entries(primary)).some((e: any) => e.kind === 'board.message' && e.data.from === branch.id && e.data.delivery === 'note')); diff --git a/host/tests/extension_names.rs b/host/tests/extension_names.rs new file mode 100644 index 0000000..7f48f90 --- /dev/null +++ b/host/tests/extension_names.rs @@ -0,0 +1,165 @@ +//! Parse extension modules and reject free runtime identifiers outside the world runtime globals. +use deno_ast::swc::{ + ast::{Ident, TsInterfaceDecl, TsType}, + common::SyntaxContext, + ecma_visit::{Visit, VisitWith}, +}; +use deno_ast::{MediaType, ParseParams, ProgramRef, parse_module}; +use std::path::Path; + +// Standard JavaScript globals. World extensions also see Deno. +const GLOBALS: &[&str] = &[ + "undefined", + "globalThis", + "Deno", + "console", + "crypto", + "fetch", + "performance", + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "queueMicrotask", + "structuredClone", + "atob", + "btoa", + "parseInt", + "parseFloat", + "isNaN", + "Infinity", + "NaN", + "Object", + "Function", + "Boolean", + "Number", + "BigInt", + "String", + "Symbol", + "Array", + "Map", + "Set", + "WeakMap", + "WeakSet", + "WeakRef", + "FinalizationRegistry", + "Promise", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "AggregateError", + "JSON", + "Math", + "Reflect", + "Proxy", + "Intl", + "Iterator", + "Uint8Array", + "ArrayBuffer", + "TextEncoder", + "TextDecoder", + "URL", + "URLSearchParams", + "AbortController", + "AbortSignal", + "Request", + "Response", + "Headers", + "ReadableStream", + "WritableStream", + "TransformStream", + "Blob", + "File", + "FormData", + "DOMException", + "encodeURIComponent", + "decodeURIComponent", +]; +fn undefined(source: &str) -> Vec { + let parsed = parse_module(ParseParams { + specifier: "file:///extension.ts".parse().unwrap(), + text: source.into(), + media_type: MediaType::TypeScript, + capture_tokens: false, + scope_analysis: true, + maybe_syntax: None, + }) + .unwrap(); + struct FreeNames { + context: SyntaxContext, + names: Vec, + } + impl Visit for FreeNames { + // Type signatures contain parameter names but do not execute. + fn visit_ts_type(&mut self, _node: &TsType) {} + fn visit_ts_interface_decl(&mut self, _node: &TsInterfaceDecl) {} + fn visit_ident(&mut self, ident: &Ident) { + if ident.ctxt == self.context && !GLOBALS.contains(&ident.sym.as_ref()) { + self.names.push(ident.sym.to_string()); + } + } + } + let mut free = FreeNames { + context: parsed.unresolved_context(), + names: Vec::new(), + }; + match parsed.program_ref() { + ProgramRef::Module(module) => module.visit_with(&mut free), + ProgramRef::Script(script) => script.visit_with(&mut free), + } + free.names.sort(); + free.names.dedup(); + free.names +} +fn scan(directory: &Path, failures: &mut Vec) { + for entry in std::fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + scan(&path, failures); + } else if path.extension().is_some_and(|ext| ext == "ts") + && !path.to_string_lossy().ends_with(".test.ts") + { + let names = undefined(&std::fs::read_to_string(&path).unwrap()); + if !names.is_empty() { + failures.push(format!("{}: {}", path.display(), names.join(", "))); + } + } + } +} +#[test] +fn extension_identifiers_are_defined() { + let mut failures = Vec::new(); + scan( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"), + &mut failures, + ); + assert!( + failures.is_empty(), + "Undefined identifiers:\n{}", + failures.join("\n") + ); +} +#[test] +fn catches_the_missing_route_import() { + let source = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions/messages/route.ts"), + ) + .unwrap(); + let old = source.replace( + "import { ConversationsApi } from '../conversations/api.ts'\n", + "", + ); + assert!( + undefined(&old) + .iter() + .any(|name| name == "ConversationsApi") + ); + assert!(undefined("import { Api } from 'service'; type Callback = (arg: UnknownType) => void; interface Service { use(parameter: UnknownType): void }; function use(host: any) { const local = Api; return host.need(local) }").is_empty()); + assert_eq!( + undefined("function use(host: any) { return host.need(MissingApi) }"), + ["MissingApi"] + ); +} diff --git a/host/tests/messages.rs b/host/tests/messages.rs index 628d3b5..11c2eed 100644 --- a/host/tests/messages.rs +++ b/host/tests/messages.rs @@ -48,6 +48,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { "extensions/lineage", "extensions/models", "extensions/core", + "extensions/conversations", "agents", ] { std::fs::create_dir_all(repo.join(directory))?; @@ -70,6 +71,10 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { repo.join("extensions/probe/server.ts"), "export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }", )?; + std::fs::copy( + source.join("extensions/conversations/api.ts"), + repo.join("extensions/conversations/api.ts"), + )?; std::fs::copy( source.join("extensions/core/agents.ts"), repo.join("extensions/core/agents.ts"), @@ -88,7 +93,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { )?; std::fs::write( repo.join("agents/coordinator.md"), - "---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n", + "---\nname: coordinator\ndescription: Coordinate\nmodel: faux/coordinator\nisolate: none\n---\nCoordinator agent\n", )?; for args in [ vec!["init", "-b", "main"],