Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 52 additions & 10 deletions packages/web/server/create-openscout-web-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1968,10 +1968,16 @@ describe("createOpenScoutWebServer", () => {
expect(dismissed.reminders.find((reminder) => reminder.id === due.reminder.id)?.status).toBe("dismissed");
});

test("returns a setup error when Scoutbot assistant has no OpenAI key", async () => {
test("falls back to local Codex when Scoutbot assistant has no OpenAI key", async () => {
useIsolatedOpenScoutHome();
delete process.env.OPENAI_API_KEY;
let fetchCalled = false;
const codexCalls: Array<{
sessionId: string;
threadId?: string | null;
prompt: string;
systemPrompt: string;
}> = [];
globalThis.fetch = (async () => {
fetchCalled = true;
return new Response("{}", { status: 200 });
Expand All @@ -1981,6 +1987,20 @@ describe("createOpenScoutWebServer", () => {
currentDirectory: "/tmp/openscout",
assetMode: "static",
staticRoot: makeStaticRoot(),
scoutbotAssistant: {
invokeCodex: async (input) => {
codexCalls.push({
sessionId: input.sessionId,
threadId: input.threadId,
prompt: input.prompt,
systemPrompt: input.systemPrompt,
});
return {
output: "Codex fallback works.",
threadId: "codex-thread-1",
};
},
},
});

const response = await server.app.request("http://localhost/api/scoutbot/chat", {
Expand All @@ -1989,17 +2009,28 @@ describe("createOpenScoutWebServer", () => {
body: JSON.stringify({ body: "state?" }),
});

expect(response.status).toBe(503);
expect(await response.json()).toEqual({
error: "An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.",
});
expect(response.status).toBe(200);
const json = await response.json() as {
reply: { body: string };
responseId: string | null;
session: { messages: Array<{ role: string; body: string }> };
};
expect(json.reply.body).toBe("Codex fallback works.");
expect(json.responseId).toBe("codex-thread-1");
expect(json.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(fetchCalled).toBe(false);
expect(codexCalls).toHaveLength(1);
expect(codexCalls[0].threadId).toBeNull();
expect(codexCalls[0].prompt).toContain("Operator request:");
expect(codexCalls[0].prompt).toContain("Current Scout control-plane snapshot");
expect(codexCalls[0].systemPrompt).toContain("not a peer agent");
});

test("does not use a transient request supplied OpenAI key for Scoutbot assistant", async () => {
test("ignores a transient request supplied OpenAI key and still uses configured providers", async () => {
useIsolatedOpenScoutHome();
delete process.env.OPENAI_API_KEY;
let fetchCalled = false;
const codexCalls: Array<{ prompt: string }> = [];
globalThis.fetch = (async (_input, init) => {
fetchCalled = true;
void init;
Expand All @@ -2016,6 +2047,15 @@ describe("createOpenScoutWebServer", () => {
currentDirectory: "/tmp/openscout",
assetMode: "static",
staticRoot: makeStaticRoot(),
scoutbotAssistant: {
invokeCodex: async (input) => {
codexCalls.push({ prompt: input.prompt });
return {
output: "Request key ignored; Codex handled this.",
threadId: "codex-thread-request-key",
};
},
},
});

const response = await server.app.request("http://localhost/api/scoutbot/chat", {
Expand All @@ -2027,11 +2067,13 @@ describe("createOpenScoutWebServer", () => {
}),
});

expect(response.status).toBe(503);
expect(await response.json()).toEqual({
error: "An OpenAI API key is required for Scoutbot assistant. Add one in Settings > Credentials or set OPENAI_API_KEY.",
});
expect(response.status).toBe(200);
const json = await response.json() as { reply: { body: string }; responseId: string | null };
expect(json.reply.body).toContain("Codex handled this");
expect(json.responseId).toBe("codex-thread-request-key");
expect(fetchCalled).toBe(false);
expect(codexCalls).toHaveLength(1);
expect(codexCalls[0].prompt).not.toContain("sk-request-test");
});

test("saves and uses the local Scoutbot OpenAI credential store", async () => {
Expand Down
57 changes: 55 additions & 2 deletions packages/web/server/create-openscout-web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import {
import {
createScoutbotAssistantService,
ScoutbotAssistantError,
type ScoutbotCodexAssistantInvoker,
type ScoutbotBrief,
type ScoutbotBriefCapture,
type ScoutbotBriefObservation,
Expand All @@ -138,7 +139,7 @@ import {
startScoutbotRunner,
type ScoutbotRunnerHandle,
} from "./scoutbot/runner.ts";
import { SCOUTBOT_AGENT_ID } from "./scoutbot/role.ts";
import { SCOUTBOT_AGENT_ID, SCOUTBOT_REASONING_EFFORT } from "./scoutbot/role.ts";
import { loadServiceBudgets } from "./service-budgets.ts";
import {
buildWorkMaterialsInventory,
Expand Down Expand Up @@ -186,8 +187,12 @@ import {
saveOpenScoutOnboardingProject,
skipOpenScoutOnboarding,
} from "@openscout/runtime/onboarding";
import { relayAgentRuntimeDirectory } from "@openscout/runtime/support-paths";
import { relayAgentLogsDirectory, relayAgentRuntimeDirectory } from "@openscout/runtime/support-paths";
import { readSessionCatalogSync } from "@openscout/runtime/claude-stream-json";
import {
invokeCodexAppServerAgent,
normalizeCodexAppServerLaunchArgs,
} from "@openscout/runtime/codex-app-server";

function parseConversationKinds(value: string | undefined): ConversationKind[] | undefined {
const trimmed = value?.trim();
Expand Down Expand Up @@ -287,6 +292,9 @@ export type CreateOpenScoutWebServerOptions = {
createVantageHandoff?: (request: OpenScoutVantageHandoffInput) => Promise<OpenScoutVantageHandoff>;
terminalRelayHealthcheck?: () => Promise<boolean>;
revealPath?: (targetPath: string) => Promise<void> | void;
scoutbotAssistant?: {
invokeCodex?: ScoutbotCodexAssistantInvoker;
};
scoutbot?: {
enabled?: boolean;
brokerBaseUrl?: string;
Expand Down Expand Up @@ -1885,6 +1893,49 @@ async function resolveScoutbotCredentialState(
};
}

function createDefaultScoutbotCodexInvoker(currentDirectory: string): ScoutbotCodexAssistantInvoker {
return async (input) => {
const runtimeName = `scoutbot-assistant-${sanitizeSupportPathSegment(input.sessionId)}`;
const result = await invokeCodexAppServerAgent({
agentName: "scoutbot-assistant",
sessionId: input.sessionId,
cwd: currentDirectory,
systemPrompt: input.systemPrompt,
runtimeDirectory: relayAgentRuntimeDirectory(runtimeName),
logsDirectory: relayAgentLogsDirectory(runtimeName),
launchArgs: buildScoutbotAssistantCodexLaunchArgs(process.env),
...(input.threadId ? { threadId: input.threadId } : {}),
prompt: input.prompt,
timeoutMs: input.timeoutMs,
approvalPolicy: "never",
sandbox: "read-only",
});
return {
output: result.output,
threadId: result.threadId,
};
};
}

function buildScoutbotAssistantCodexLaunchArgs(env: NodeJS.ProcessEnv): string[] {
const args: string[] = [];
const model = env.OPENSCOUT_SCOUTBOT_CODEX_MODEL?.trim();
const reasoningEffort = env.OPENSCOUT_SCOUTBOT_CODEX_REASONING_EFFORT?.trim()
|| SCOUTBOT_REASONING_EFFORT;
if (model) args.push("--model", model);
if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort);
return normalizeCodexAppServerLaunchArgs(args);
}

function sanitizeSupportPathSegment(value: string): string {
const sanitized = value
.trim()
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "default";
}

function renderScoutLocalPortal(input: {
requestUrl: string;
portalHost: string;
Expand Down Expand Up @@ -2133,6 +2184,8 @@ export async function createOpenScoutWebServer(
const config = await loadScoutRelayConfig().catch(() => null);
return config?.openaiApiKey ?? scoutbotCredentials.getOpenAIKey();
},
invokeCodex: options.scoutbotAssistant?.invokeCodex
?? createDefaultScoutbotCodexInvoker(currentDirectory),
});
let scoutbotRunner: ScoutbotRunnerHandle | null = null;
if (options.scoutbot?.enabled) {
Expand Down
Loading