- commit
- 1d0871e
- parent
- a6e3a34
- author
- im_wower
- date
- 2026-03-28 02:54:19 +0800 CST
feat: add phase1 browser claude target
3 files changed,
+242,
-10
+154,
-3
1@@ -27,6 +27,7 @@ import {
2 normalizeBaaInstruction,
3 parseBaaInstructionBlock,
4 parseConductorCliRequest,
5+ routeBaaInstruction,
6 writeHttpResponse
7 } from "../dist/index.js";
8
9@@ -236,6 +237,44 @@ async function createLocalApiFixture(options = {}) {
10 };
11 }
12
13+function createInstructionEnvelope({
14+ blockIndex = 0,
15+ params = null,
16+ paramsKind = params == null
17+ ? "none"
18+ : typeof params === "string"
19+ ? "inline_string"
20+ : "inline_json",
21+ target = "conductor",
22+ tool = "describe"
23+} = {}) {
24+ return {
25+ assistantMessageId: "msg-route-test",
26+ blockIndex,
27+ conversationId: "conv-route-test",
28+ dedupeBasis: {
29+ assistant_message_id: "msg-route-test",
30+ block_index: blockIndex,
31+ conversation_id: "conv-route-test",
32+ params,
33+ platform: "claude",
34+ target,
35+ tool,
36+ version: "baa.v1"
37+ },
38+ dedupeKey: `dedupe:${blockIndex}:${target}:${tool}`,
39+ envelopeVersion: "baa.v1",
40+ instructionId: `instruction-${blockIndex}`,
41+ params,
42+ paramsKind,
43+ platform: "claude",
44+ rawBlock: "```baa\nplaceholder\n```",
45+ rawInstruction: `@${target}::${tool}`,
46+ target,
47+ tool
48+ };
49+}
50+
51 async function startCodexdStubServer() {
52 const requests = [];
53 const sessions = [
54@@ -646,6 +685,35 @@ test("BAA instruction normalization keeps auditable fields and stable dedupe key
55 });
56 });
57
58+test("routeBaaInstruction maps browser.claude send/current to the existing local browser routes", () => {
59+ const sendRoute = routeBaaInstruction(createInstructionEnvelope({
60+ params: "hello claude from baa",
61+ target: "browser.claude",
62+ tool: "send"
63+ }));
64+ assert.deepEqual(sendRoute, {
65+ body: {
66+ prompt: "hello claude from baa"
67+ },
68+ key: "local.browser.claude.send",
69+ method: "POST",
70+ path: "/v1/browser/claude/send",
71+ requiresSharedToken: false
72+ });
73+
74+ const currentRoute = routeBaaInstruction(createInstructionEnvelope({
75+ target: "browser.claude",
76+ tool: "current"
77+ }));
78+ assert.deepEqual(currentRoute, {
79+ body: null,
80+ key: "local.browser.claude.current",
81+ method: "GET",
82+ path: "/v1/browser/claude/current",
83+ requiresSharedToken: false
84+ });
85+});
86+
87 test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
88 const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
89 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-"));
90@@ -749,6 +817,74 @@ test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed
91 }
92 });
93
94+test("BaaInstructionCenter executes browser.claude send/current through the Phase 1 route layer", async () => {
95+ const { controlPlane, repository, snapshot } = await createLocalApiFixture();
96+ const browser = createBrowserBridgeStub();
97+ const center = new BaaInstructionCenter({
98+ localApiContext: {
99+ ...browser.context,
100+ fetchImpl: globalThis.fetch,
101+ repository,
102+ snapshotLoader: () => snapshot
103+ }
104+ });
105+ const message = [
106+ "```baa",
107+ "@browser.claude::send::hello from instruction center",
108+ "```",
109+ "",
110+ "```baa",
111+ "@browser.claude::current",
112+ "```"
113+ ].join("\n");
114+
115+ try {
116+ const result = await center.processAssistantMessage({
117+ assistantMessageId: "msg-browser-claude-1",
118+ conversationId: "conv-browser-claude-1",
119+ platform: "claude",
120+ text: message
121+ });
122+
123+ assert.equal(result.status, "executed");
124+ assert.equal(result.denied.length, 0);
125+ assert.equal(result.executions.length, 2);
126+
127+ const sendExecution = result.executions.find((execution) => execution.tool === "send");
128+ assert.ok(sendExecution);
129+ assert.equal(sendExecution.ok, true);
130+ assert.equal(sendExecution.route.path, "/v1/browser/claude/send");
131+ assert.equal(sendExecution.data.conversation.conversation_id, "conv-1");
132+ assert.equal(sendExecution.data.response.accepted, true);
133+
134+ const currentExecution = result.executions.find((execution) => execution.tool === "current");
135+ assert.ok(currentExecution);
136+ assert.equal(currentExecution.ok, true);
137+ assert.equal(currentExecution.route.path, "/v1/browser/claude/current");
138+ assert.equal(currentExecution.data.conversation.conversation_id, "conv-1");
139+ assert.equal(currentExecution.data.messages.length, 2);
140+
141+ const completionCall = browser.calls.find(
142+ (call) =>
143+ call.kind === "apiRequest"
144+ && call.path === "/api/organizations/org-1/chat_conversations/conv-1/completion"
145+ );
146+ assert.ok(completionCall);
147+ assert.deepEqual(completionCall.body, {
148+ prompt: "hello from instruction center"
149+ });
150+
151+ const currentConversationCall = browser.calls.find(
152+ (call) =>
153+ call.kind === "apiRequest"
154+ && call.path === "/api/organizations/org-1/chat_conversations/conv-1"
155+ );
156+ assert.ok(currentConversationCall);
157+ } finally {
158+ controlPlane.close();
159+ }
160+});
161+
162 test("BaaInstructionCenter keeps supported instructions running when one instruction is denied", async () => {
163 const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
164 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-partial-deny-"));
165@@ -830,7 +966,7 @@ test("BaaInstructionCenter returns denied_only when every pending instruction is
166 "```",
167 "",
168 "```baa",
169- "@browser.claude::current",
170+ "@browser.claude::reload",
171 "```"
172 ].join("\n");
173
174@@ -846,8 +982,23 @@ test("BaaInstructionCenter returns denied_only when every pending instruction is
175 assert.equal(result.executions.length, 0);
176 assert.equal(result.denied.length, 2);
177 assert.deepEqual(
178- result.denied.map((entry) => entry.instruction.target),
179- ["browser.chatgpt", "browser.claude"]
180+ result.denied.map((entry) => ({
181+ code: entry.code,
182+ target: entry.instruction.target,
183+ tool: entry.instruction.tool
184+ })),
185+ [
186+ {
187+ code: "unsupported_target",
188+ target: "browser.chatgpt",
189+ tool: "send"
190+ },
191+ {
192+ code: "unsupported_tool",
193+ target: "browser.claude",
194+ tool: "reload"
195+ }
196+ ]
197 );
198 } finally {
199 controlPlane.close();
1@@ -1,7 +1,6 @@
2 import type { BaaInstructionEnvelope } from "./types.js";
3
4-const SUPPORTED_TARGETS = new Set(["conductor", "system"]);
5-const SUPPORTED_TOOLS = new Set([
6+const CONDUCTOR_TOOLS = new Set([
7 "describe",
8 "describe/business",
9 "describe/control",
10@@ -10,6 +9,15 @@ const SUPPORTED_TOOLS = new Set([
11 "files/write",
12 "status"
13 ]);
14+const BROWSER_CLAUDE_TOOLS = new Set([
15+ "current",
16+ "send"
17+]);
18+const SUPPORTED_TARGET_TOOLS = new Map([
19+ ["conductor", CONDUCTOR_TOOLS],
20+ ["system", CONDUCTOR_TOOLS],
21+ ["browser.claude", BROWSER_CLAUDE_TOOLS]
22+]);
23
24 export interface BaaInstructionPolicyDecision {
25 code: string | null;
26@@ -20,7 +28,9 @@ export interface BaaInstructionPolicyDecision {
27 export function evaluateBaaInstructionPolicy(
28 instruction: BaaInstructionEnvelope
29 ): BaaInstructionPolicyDecision {
30- if (!SUPPORTED_TARGETS.has(instruction.target)) {
31+ const supportedTools = SUPPORTED_TARGET_TOOLS.get(instruction.target);
32+
33+ if (!supportedTools) {
34 return {
35 code: "unsupported_target",
36 message: `Target "${instruction.target}" is not supported in Phase 1.`,
37@@ -28,7 +38,7 @@ export function evaluateBaaInstructionPolicy(
38 };
39 }
40
41- if (!SUPPORTED_TOOLS.has(instruction.tool)) {
42+ if (!supportedTools.has(instruction.tool)) {
43 return {
44 code: "unsupported_tool",
45 message: `Tool "${instruction.tool}" is not supported in Phase 1.`,
1@@ -1,6 +1,5 @@
2 import type {
3 BaaInstructionEnvelope,
4- BaaInstructionParams,
5 BaaInstructionRoute,
6 BaaJsonObject
7 } from "./types.js";
8@@ -129,7 +128,37 @@ function normalizeFileWriteBody(instruction: BaaInstructionEnvelope): BaaJsonObj
9 return params;
10 }
11
12-export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
13+function normalizeBrowserClaudeSendBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
14+ if (typeof instruction.params === "string") {
15+ return {
16+ prompt: requireNonEmptyStringParam(instruction, "prompt", true)
17+ };
18+ }
19+
20+ const params = requireJsonObjectParams(instruction);
21+ const prompt = params.prompt;
22+ const unexpectedKeys = Object.keys(params).filter((key) => key !== "prompt");
23+
24+ if (typeof prompt !== "string" || prompt.trim() === "") {
25+ throw new BaaInstructionRouteError(
26+ instruction.blockIndex,
27+ `${instruction.target}::${instruction.tool} JSON params must include a non-empty "prompt".`
28+ );
29+ }
30+
31+ if (unexpectedKeys.length > 0) {
32+ throw new BaaInstructionRouteError(
33+ instruction.blockIndex,
34+ `${instruction.target}::${instruction.tool} JSON params only support string "prompt".`
35+ );
36+ }
37+
38+ return {
39+ prompt: prompt.trim()
40+ };
41+}
42+
43+function routeLocalInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
44 switch (instruction.tool) {
45 case "describe":
46 requireNoParams(instruction);
47@@ -194,7 +223,49 @@ export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaIns
48 default:
49 throw new BaaInstructionRouteError(
50 instruction.blockIndex,
51- `No Phase 1 route exists for tool "${instruction.tool}".`
52+ `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
53+ );
54+ }
55+}
56+
57+function routeBrowserClaudeInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
58+ switch (instruction.tool) {
59+ case "send":
60+ return {
61+ body: normalizeBrowserClaudeSendBody(instruction),
62+ key: "local.browser.claude.send",
63+ method: "POST",
64+ path: "/v1/browser/claude/send",
65+ requiresSharedToken: false
66+ };
67+ case "current":
68+ requireNoParams(instruction);
69+ return {
70+ body: null,
71+ key: "local.browser.claude.current",
72+ method: "GET",
73+ path: "/v1/browser/claude/current",
74+ requiresSharedToken: false
75+ };
76+ default:
77+ throw new BaaInstructionRouteError(
78+ instruction.blockIndex,
79+ `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
80+ );
81+ }
82+}
83+
84+export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
85+ switch (instruction.target) {
86+ case "conductor":
87+ case "system":
88+ return routeLocalInstruction(instruction);
89+ case "browser.claude":
90+ return routeBrowserClaudeInstruction(instruction);
91+ default:
92+ throw new BaaInstructionRouteError(
93+ instruction.blockIndex,
94+ `No Phase 1 route exists for ${instruction.target}::${instruction.tool}.`
95 );
96 }
97 }