303 lines
9.5 KiB
TypeScript
303 lines
9.5 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import type { SubagentConfig } from "../../agent/subagents";
|
|
import {
|
|
buildSubagentArgs,
|
|
resolveSubagentLauncher,
|
|
resolveSubagentModel,
|
|
} from "../../agent/subagents/manager";
|
|
|
|
describe("resolveSubagentLauncher", () => {
|
|
test("explicit launcher takes precedence over .ts script autodetection", () => {
|
|
const launcher = resolveSubagentLauncher(["-p", "hi"], {
|
|
env: {
|
|
LETTA_CODE_BIN: "custom-bun",
|
|
LETTA_CODE_BIN_ARGS_JSON: JSON.stringify(["run", "src/index.ts"]),
|
|
} as NodeJS.ProcessEnv,
|
|
argv: ["bun", "/tmp/dev-entry.ts"],
|
|
execPath: "/opt/homebrew/bin/bun",
|
|
platform: "darwin",
|
|
});
|
|
|
|
expect(launcher).toEqual({
|
|
command: "custom-bun",
|
|
args: ["run", "src/index.ts", "-p", "hi"],
|
|
});
|
|
});
|
|
|
|
test("explicit launcher takes precedence over .js script autodetection", () => {
|
|
const launcher = resolveSubagentLauncher(["-p", "hi"], {
|
|
env: {
|
|
LETTA_CODE_BIN: "custom-node",
|
|
} as NodeJS.ProcessEnv,
|
|
argv: ["node", "/tmp/letta.js"],
|
|
execPath: "/usr/local/bin/node",
|
|
platform: "win32",
|
|
});
|
|
|
|
expect(launcher).toEqual({
|
|
command: "custom-node",
|
|
args: ["-p", "hi"],
|
|
});
|
|
});
|
|
|
|
test("preserves existing .ts dev behavior for any ts entrypoint", () => {
|
|
const launcher = resolveSubagentLauncher(
|
|
["--output-format", "stream-json"],
|
|
{
|
|
env: {} as NodeJS.ProcessEnv,
|
|
argv: ["bun", "/tmp/custom-runner.ts"],
|
|
execPath: "/opt/homebrew/bin/bun",
|
|
platform: "darwin",
|
|
},
|
|
);
|
|
|
|
expect(launcher).toEqual({
|
|
command: "/opt/homebrew/bin/bun",
|
|
args: ["/tmp/custom-runner.ts", "--output-format", "stream-json"],
|
|
});
|
|
});
|
|
|
|
test("uses node runtime for bundled js on win32", () => {
|
|
const launcher = resolveSubagentLauncher(["-p", "prompt"], {
|
|
env: {} as NodeJS.ProcessEnv,
|
|
argv: ["node", "C:\\Program Files\\Letta\\letta.js"],
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
platform: "win32",
|
|
});
|
|
|
|
expect(launcher).toEqual({
|
|
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
args: ["C:\\Program Files\\Letta\\letta.js", "-p", "prompt"],
|
|
});
|
|
});
|
|
|
|
test("keeps direct js spawn behavior on non-win32", () => {
|
|
const launcher = resolveSubagentLauncher(["-p", "prompt"], {
|
|
env: {} as NodeJS.ProcessEnv,
|
|
argv: ["node", "/usr/local/lib/letta.js"],
|
|
execPath: "/usr/local/bin/node",
|
|
platform: "linux",
|
|
});
|
|
|
|
expect(launcher).toEqual({
|
|
command: "/usr/local/lib/letta.js",
|
|
args: ["-p", "prompt"],
|
|
});
|
|
});
|
|
|
|
test("falls back to global letta when no launcher hints available", () => {
|
|
const launcher = resolveSubagentLauncher(["-p", "prompt"], {
|
|
env: {} as NodeJS.ProcessEnv,
|
|
argv: ["node", ""],
|
|
execPath: "/usr/local/bin/node",
|
|
platform: "linux",
|
|
});
|
|
|
|
expect(launcher).toEqual({
|
|
command: "letta",
|
|
args: ["-p", "prompt"],
|
|
});
|
|
});
|
|
|
|
test("keeps explicit launcher with spaces as a single command token", () => {
|
|
const launcher = resolveSubagentLauncher(
|
|
["--output-format", "stream-json"],
|
|
{
|
|
env: {
|
|
LETTA_CODE_BIN:
|
|
'"C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd"',
|
|
} as NodeJS.ProcessEnv,
|
|
argv: ["node", "C:\\Program Files\\Letta\\letta.js"],
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
platform: "win32",
|
|
},
|
|
);
|
|
|
|
expect(launcher).toEqual({
|
|
command: "C:\\Users\\Example User\\AppData\\Roaming\\npm\\letta.cmd",
|
|
args: ["--output-format", "stream-json"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildSubagentArgs", () => {
|
|
const baseConfig: SubagentConfig = {
|
|
name: "test-subagent",
|
|
description: "test",
|
|
systemPrompt: "test prompt",
|
|
allowedTools: "all",
|
|
recommendedModel: "inherit",
|
|
skills: [],
|
|
memoryBlocks: "none",
|
|
mode: "stateful",
|
|
};
|
|
|
|
test("adds --no-memfs for newly spawned subagents by default", () => {
|
|
const args = buildSubagentArgs("test-subagent", baseConfig, null, "hello");
|
|
|
|
expect(args).toContain("--init-blocks");
|
|
expect(args).toContain("none");
|
|
expect(args).toContain("--no-memfs");
|
|
});
|
|
|
|
test("does not force --no-memfs when deploying an existing subagent agent", () => {
|
|
const args = buildSubagentArgs(
|
|
"test-subagent",
|
|
baseConfig,
|
|
null,
|
|
"hello",
|
|
"agent-existing",
|
|
);
|
|
|
|
expect(args).toContain("--agent");
|
|
expect(args).not.toContain("--new-agent");
|
|
expect(args).not.toContain("--no-memfs");
|
|
});
|
|
});
|
|
|
|
describe("resolveSubagentModel", () => {
|
|
test("prefers BYOK-swapped handle when available", async () => {
|
|
const cases = [
|
|
{ parentProvider: "lc-anthropic", baseProvider: "anthropic" },
|
|
{ parentProvider: "lc-openai", baseProvider: "openai" },
|
|
{ parentProvider: "lc-zai", baseProvider: "zai" },
|
|
{ parentProvider: "lc-gemini", baseProvider: "google_ai" },
|
|
{ parentProvider: "lc-openrouter", baseProvider: "openrouter" },
|
|
{ parentProvider: "lc-minimax", baseProvider: "minimax" },
|
|
{ parentProvider: "lc-bedrock", baseProvider: "bedrock" },
|
|
{ parentProvider: "chatgpt-plus-pro", baseProvider: "chatgpt-plus-pro" },
|
|
];
|
|
|
|
for (const { parentProvider, baseProvider } of cases) {
|
|
const recommendedHandle = `${baseProvider}/test-model`;
|
|
const swappedHandle = `${parentProvider}/test-model`;
|
|
const parentHandle = `${parentProvider}/parent-model`;
|
|
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: recommendedHandle,
|
|
parentModelHandle: parentHandle,
|
|
availableHandles: new Set([recommendedHandle, swappedHandle]),
|
|
});
|
|
|
|
expect(result).toBe(swappedHandle);
|
|
}
|
|
});
|
|
|
|
test("falls back to parent model when recommended is unavailable", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "anthropic/test-model",
|
|
parentModelHandle: "lc-anthropic/parent-model",
|
|
availableHandles: new Set(),
|
|
});
|
|
|
|
expect(result).toBe("lc-anthropic/parent-model");
|
|
});
|
|
|
|
test("BYOK parent ignores base-provider recommended when swap is unavailable", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "anthropic/test-model",
|
|
parentModelHandle: "lc-anthropic/parent-model",
|
|
availableHandles: new Set(["anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("lc-anthropic/parent-model");
|
|
});
|
|
|
|
test("BYOK parent accepts recommended handle when already using same BYOK prefix", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "lc-anthropic/test-model",
|
|
parentModelHandle: "lc-anthropic/parent-model",
|
|
availableHandles: new Set(["lc-anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("lc-anthropic/test-model");
|
|
});
|
|
|
|
test("uses recommended model when parent is not BYOK and model is available", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "anthropic/test-model",
|
|
parentModelHandle: "anthropic/parent-model",
|
|
availableHandles: new Set(["anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("anthropic/test-model");
|
|
});
|
|
|
|
test("explicit user model overrides all other resolution", async () => {
|
|
const result = await resolveSubagentModel({
|
|
userModel: "lc-openrouter/custom-model",
|
|
recommendedModel: "anthropic/test-model",
|
|
parentModelHandle: "lc-anthropic/parent-model",
|
|
availableHandles: new Set(["lc-anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("lc-openrouter/custom-model");
|
|
});
|
|
|
|
test("inherits parent when recommended is inherit", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "inherit",
|
|
parentModelHandle: "lc-anthropic/parent-model",
|
|
availableHandles: new Set(["lc-anthropic/parent-model"]),
|
|
});
|
|
|
|
expect(result).toBe("lc-anthropic/parent-model");
|
|
});
|
|
|
|
test("uses auto default when available", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "sonnet-4.5",
|
|
availableHandles: new Set(["letta/auto", "anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("letta/auto");
|
|
});
|
|
|
|
test("uses auto-fast default for free tier when available", async () => {
|
|
const result = await resolveSubagentModel({
|
|
billingTier: "free",
|
|
availableHandles: new Set(["letta/auto-fast", "letta/auto"]),
|
|
});
|
|
|
|
expect(result).toBe("letta/auto-fast");
|
|
});
|
|
|
|
test("free tier falls back to auto when auto-fast is unavailable", async () => {
|
|
const result = await resolveSubagentModel({
|
|
billingTier: "free",
|
|
availableHandles: new Set(["letta/auto"]),
|
|
});
|
|
|
|
expect(result).toBe("letta/auto");
|
|
});
|
|
|
|
test("falls back when auto is unavailable", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "anthropic/test-model",
|
|
availableHandles: new Set(["anthropic/test-model"]),
|
|
});
|
|
|
|
expect(result).toBe("anthropic/test-model");
|
|
});
|
|
|
|
test("keeps inherit behavior when auto is unavailable", async () => {
|
|
const result = await resolveSubagentModel({
|
|
recommendedModel: "inherit",
|
|
parentModelHandle: "openai/gpt-5",
|
|
availableHandles: new Set(["openai/gpt-5"]),
|
|
});
|
|
|
|
expect(result).toBe("openai/gpt-5");
|
|
});
|
|
|
|
test("user-provided model still overrides default auto", async () => {
|
|
const result = await resolveSubagentModel({
|
|
userModel: "openai/gpt-5",
|
|
recommendedModel: "sonnet-4.5",
|
|
availableHandles: new Set(["letta/auto", "openai/gpt-5"]),
|
|
});
|
|
|
|
expect(result).toBe("openai/gpt-5");
|
|
});
|
|
});
|