/**
 * Agent.createSession's Workspace handling and vault injection (no network needed; only
 * constructs the Session, never sends a request).
 *
 * Regression: an explicitly given Workspace must be an existing directory. When it
 * does not exist, a clear error must be thrown rather than auto-creating it, and bash must not
 * be started with an invalid cwd after Session creation, which would throw a misleading
 * `spawn bash ENOENT`. A temporary workspace is only created when no Workspace is specified.
 *
 * vault: the Agent vault's (agent_state/.vault.toml) **key names** are
 * injected into the assembled system prompt; values are never injected.
 */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
  addModel,
  createAgent,
  DEFAULT_AGENT_ID,
  DEFAULT_PROJECT_ID,
  installSkill,
  loadProjectConfig,
  saveProjectConfig,
  setVaultEntry,
} from "../src/index.js";
import { metaMaxTokens } from "../src/agent.js";
import { mapThinkingLevel } from "../src/llm/index.js";
import { stubProviderKeys } from "./provider-keys.js";
import type { EnvironmentConfig, EnvironmentServices, SubagentRunner } from "../src/interfaces.js";

// Captures the services buildRuntime hands to each Environment, so tests can drive the REAL
// subagent runner (the spawn closure in agent.ts). Spawning only constructs the child Session,
// and pulling a single message from handle.run yields the child session_meta before any LLM
// request is issued — no network is ever touched. The wrapper is otherwise transparent, so
// every other test in this file behaves as with the real class.
const capturedEnvServices = vi.hoisted(() => ({ list: [] as unknown[] }));
vi.mock("../src/environment/index.js", async (importOriginal) => {
  const mod = await importOriginal<typeof import("../src/environment/index.js")>();
  class CapturingEnvironment extends mod.Environment {
    constructor(config: EnvironmentConfig) {
      super(config);
      capturedEnvServices.list.push(config.services);
    }
  }
  return { ...mod, Environment: CapturingEnvironment };
});

// Captures every GenerativeModelConfig buildRuntime constructs: the effective thinking level
// is no longer observable through session_meta (it holds invariants only) — assertions read
// the construction default from the captured config instead.
const capturedLLMConfigs = vi.hoisted(() => ({ list: [] as { thinkingLevel?: string }[] }));
vi.mock("../src/llm/index.js", async (importOriginal) => {
  const mod = await importOriginal<typeof import("../src/llm/index.js")>();
  class CapturingGenerativeModel extends mod.GenerativeModel {
    constructor(config: ConstructorParameters<typeof mod.GenerativeModel>[0]) {
      super(config);
      capturedLLMConfigs.list.push(config as { thinkingLevel?: string });
    }
  }
  return { ...mod, GenerativeModel: CapturingGenerativeModel };
});

let tmpRoot: string;
let prevHome: string | undefined;
let restoreKeys: () => void;

beforeEach(async () => {
  prevHome = process.env.PENGUIN_HOME;
  tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-harness-"));
  process.env.PENGUIN_HOME = tmpRoot;
  restoreKeys = stubProviderKeys();
});

afterEach(async () => {
  if (prevHome === undefined) delete process.env.PENGUIN_HOME;
  else process.env.PENGUIN_HOME = prevHome;
  restoreKeys();
  await fs.rm(tmpRoot, { recursive: true, force: true });
});

// effectiveMaxContextLength moved to llm/context-limits.ts; its derivation (window-derived
// compaction threshold, issue #218) is covered in test/context-limits.test.ts.

describe("metaMaxTokens (meta-request budget tightened by the per-model cap)", () => {
  it("keeps the budget unless the per-model cap is smaller; never raises it", () => {
    expect(metaMaxTokens(300, undefined)).toBe(300); // no per-model cap: the budget as-is
    expect(metaMaxTokens(300, 8000)).toBe(300); // ample cap: the small budget stays
    expect(metaMaxTokens(300, 128)).toBe(128); // pinned below the budget: the cap binds
    expect(metaMaxTokens(2048, 1024)).toBe(1024); // vision-describer budget, same rule
    expect(metaMaxTokens(300, -1)).toBe(300); // -1 = uncapped: never tightens to -1 (issue #55 sibling)
  });
});

describe("Agent.createSession workspace handling", () => {
  it("throws a clear error when the given workspace does not exist (no auto-create)", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "nested", "does-not-exist");
    await expect(agent.createSession({ workspaceDir: ws })).rejects.toThrow(/does not exist/);
    // Must not be auto-created.
    await expect(fs.stat(ws)).rejects.toThrow();
  });

  it("throws when the given workspace path is not a directory", async () => {
    const agent = await createAgent();
    const filePath = path.join(tmpRoot, "a-file");
    await fs.writeFile(filePath, "x", "utf8");
    await expect(agent.createSession({ workspaceDir: filePath })).rejects.toThrow(
      /is not a directory/,
    );
  });

  it("accepts an existing directory and resolves it to an absolute path", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws");
    await fs.mkdir(ws, { recursive: true });
    const session = await agent.createSession({ workspaceDir: ws });
    expect(session.workspaceDir).toBe(ws);
    expect(path.isAbsolute(session.workspaceDir)).toBe(true);
  });

  it("rejects a model reference that is not in the Project config with a clear error", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-bad-model");
    await fs.mkdir(ws, { recursive: true });
    // A reference outside the config is not silently allowed (the unique key is provider +
    // model_id); the error is thrown before creating the temporary workspace.
    await expect(
      agent.createSession({
        workspaceDir: ws,
        modelId: "not-configured-model",
        provider: "custom",
      }),
    ).rejects.toThrow(/is not in the Project config/);
    await expect(
      agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-pro", provider: "openai" }),
    ).rejects.toThrow(/\(provider=openai, model_id=deepseek-v4-pro\)/);
  });

  it("passes model timeout from system_config to GenerativeModel", async () => {
    const agent = await createAgent();
    agent.state.systemConfig.model = {
      ...(agent.state.systemConfig.model ?? {}),
      timeoutMs: 3456,
    };
    const ws = path.join(tmpRoot, "ws-timeout");
    await fs.mkdir(ws, { recursive: true });

    const session = await agent.createSession({ workspaceDir: ws });
    const llm = (session as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;

    expect((llm as { requestTimeoutMs?: number }).requestTimeoutMs).toBe(3456);
  });
});

describe("Agent.createSession model reference ((provider, model_id) pair)", () => {
  it("records the pair reference in session_meta (default_model when unspecified)", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-ref-default");
    await fs.mkdir(ws, { recursive: true });
    const session = await agent.createSession({ workspaceDir: ws });
    try {
      // Defaults to the default_model reference; session_meta carries the pair reference
      // (same source that Trace writes).
      const meta = session.metaMessage.payload as { provider: string; model_id: string };
      expect(meta.provider).toBe("deepseek");
      expect(meta.model_id).toBe("deepseek-v4-flash");
      expect(session.provider).toBe("deepseek");
      expect(session.modelId).toBe("deepseek-v4-flash");
    } finally {
      session.dispose();
    }
  });

  it("selects the entry named by the pair, even when a second group sells the same model_id", async () => {
    // A user-run proxy resells claude-sonnet-4-6 under the same upstream id: the two entries
    // coexist and the pair — not the bare id — decides which one (and therefore which
    // credential and base_url) the Session runs on.
    await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
      provider: "myproxy",
      model_id: "claude-sonnet-4-6",
    });
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-ref-pair");
    await fs.mkdir(ws, { recursive: true });
    const vendor = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "anthropic",
    });
    try {
      expect(vendor.provider).toBe("anthropic");
      expect(vendor.modelId).toBe("claude-sonnet-4-6");
    } finally {
      vendor.dispose();
    }
    const proxied = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "myproxy",
    });
    try {
      expect(proxied.provider).toBe("myproxy");
      expect(proxied.modelId).toBe("claude-sonnet-4-6");
    } finally {
      proxied.dispose();
    }
  });

  it("rejects half a reference: modelId without provider, and provider without modelId", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-ref-half");
    await fs.mkdir(ws, { recursive: true });
    // A bare model_id is never resolved against the config, not even when exactly one entry
    // carries it (deepseek-v4-flash is unique here): the group is the caller's to name.
    await expect(
      agent.createSession({ workspaceDir: ws, modelId: "deepseek-v4-flash" }),
    ).rejects.toThrow(/must be given as a \(provider, model_id\) pair/);
    // The mirror case: provider alone is not a reference either.
    await expect(agent.createSession({ workspaceDir: ws, provider: "deepseek" })).rejects.toThrow(
      /must be given as a \(provider, model_id\) pair/,
    );
    // Neither half given is the documented "use the Project default" path, not an error.
    const session = await agent.createSession({ workspaceDir: ws });
    try {
      expect(session.provider).toBe("deepseek");
      expect(session.modelId).toBe("deepseek-v4-flash");
    } finally {
      session.dispose();
    }
  });
});

describe("Agent.createSession session source (session_meta origin marker)", () => {
  it("records the given source in session_meta; a user-created session carries no source key", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-source");
    await fs.mkdir(ws, { recursive: true });

    const scheduled = await agent.createSession({ workspaceDir: ws, source: "schedule" });
    try {
      expect((scheduled.metaMessage.payload as { source?: string }).source).toBe("schedule");
    } finally {
      scheduled.dispose();
    }

    // Absent = user-created: the key must not appear at all (Trace consumers treat absence as the default).
    const plain = await agent.createSession({ workspaceDir: ws });
    try {
      expect("source" in (plain.metaMessage.payload as unknown as Record<string, unknown>)).toBe(
        false,
      );
    } finally {
      plain.dispose();
    }
  });
});

describe("Agent.createSession thinking level (explicit option wins over the Agent config)", () => {
  // The session's default level lives on the LLM object (per-request overrides fall back to
  // it); session_meta holds per-session invariants only and never records a thinking level.
  const defaultLevelOf = (session: unknown): unknown =>
    (session as { engine: { deps: { llm: { defaultThinkingLevel?: unknown } } } }).engine.deps.llm
      .defaultThinkingLevel;

  it("falls back to the Agent config for the llm default; session_meta records no level", async () => {
    const agent = await createAgent();
    // The seeded Agent config pins thinking_level "medium" — the only source when no option is given.
    expect(agent.state.systemConfig.model?.thinking_level).toBe("medium");
    const ws = path.join(tmpRoot, "ws-thinking-default");
    await fs.mkdir(ws, { recursive: true });
    const session = await agent.createSession({ workspaceDir: ws });
    try {
      // session_meta contains ONLY per-session invariants: the thinking level is per-turn.
      expect(
        "thinking_level" in (session.metaMessage.payload as unknown as Record<string, unknown>),
      ).toBe(false);
      expect(defaultLevelOf(session)).toBe("medium");
      expect(mapThinkingLevel("medium")).toBeDefined(); // the name maps onto the wire enum
    } finally {
      session.dispose();
    }
  });

  it("uses an explicit thinkingLevel over the Agent config (subagent inheritance rides on this)", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-thinking-explicit");
    await fs.mkdir(ws, { recursive: true });
    const session = await agent.createSession({ workspaceDir: ws, thinkingLevel: "high" });
    try {
      expect(defaultLevelOf(session)).toBe("high");
    } finally {
      session.dispose();
    }
  });

  // The full resolution chain (the single rule, comment-pinned at Agent.configuredThinkingLevel
  // and mirrored by the web draft picker's display): Agent explicit `model.thinking_level` >
  // Project `default_chat.thinking_level` > built-in "medium".
  it("falls back to the Project's default_chat.thinking_level when the Agent config has none", async () => {
    const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
    cfg.default_chat = { thinking_level: "high" };
    await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg);
    const agent = await createAgent();
    // A hand-edited config without a level (the seeded default pins "medium").
    delete agent.state.systemConfig.model?.thinking_level;
    const ws = path.join(tmpRoot, "ws-thinking-project");
    await fs.mkdir(ws, { recursive: true });
    const session = await agent.createSession({ workspaceDir: ws });
    try {
      expect(defaultLevelOf(session)).toBe("high");
    } finally {
      session.dispose();
    }
  });

  it("Agent explicit level wins over the project default; built-in medium is the last resort", async () => {
    const cfg = await loadProjectConfig(tmpRoot, DEFAULT_PROJECT_ID);
    cfg.default_chat = { thinking_level: "low" };
    await saveProjectConfig(tmpRoot, DEFAULT_PROJECT_ID, cfg);
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-thinking-chain");
    await fs.mkdir(ws, { recursive: true });
    // The seeded config's explicit "medium" beats the project's "low".
    const explicit = await agent.createSession({ workspaceDir: ws });
    try {
      expect(defaultLevelOf(explicit)).toBe("medium");
    } finally {
      explicit.dispose();
    }
    // No explicit level and no project default -> the built-in "medium".
    const bare = await createAgent();
    delete bare.projectConfig.default_chat;
    delete bare.state.systemConfig.model?.thinking_level;
    const builtin = await bare.createSession({ workspaceDir: ws });
    try {
      expect(defaultLevelOf(builtin)).toBe("medium");
    } finally {
      builtin.dispose();
    }
  });
});

describe("run_subagent spawning follows the PARENT session (never the Project default)", () => {
  /** The subagent runner captured from the most recently constructed real Environment. */
  function lastSpawnedRunner(): SubagentRunner {
    const services = capturedEnvServices.list.at(-1) as EnvironmentServices | undefined;
    const runner = services?.subagentRunner;
    expect(runner).toBeDefined();
    return runner!;
  }

  /**
   * Spawns through the real runner and reads the child session_meta — the first message
   * handle.run yields, emitted before any LLM request; the run generator is closed right
   * after, so nothing is ever sent upstream. The child's effective thinking level is no
   * longer in session_meta (invariants only): it's read from the captured LLM config the
   * spawn constructed (the last one pushed).
   */
  async function spawnedChildMeta(
    runner: SubagentRunner,
    input: Parameters<SubagentRunner["spawn"]>[0],
  ): Promise<{
    provider: string;
    model_id: string;
    workspace: string;
    source?: string;
    llm: { thinkingLevel?: string };
  }> {
    const handle = await runner.spawn(input);
    const llm = capturedLLMConfigs.list.at(-1)!;
    try {
      const gen = handle.run({ prompt: "noop" });
      const first = await gen.next();
      expect(first.done).toBe(false);
      const msg = first.value!;
      expect(msg.type).toBe("session_meta");
      // Child messages are stamped with the child Session id as the origin hop.
      expect(msg.origin?.[0]).toBe(handle.sessionId);
      await gen.return(undefined);
      return {
        ...(msg.payload as {
          provider: string;
          model_id: string;
          workspace: string;
          source?: string;
        }),
        llm,
      };
    } finally {
      handle.dispose();
    }
  }

  it("inherits the parent's model pair, thinking level, and workspace when the args omit them", async () => {
    const agent = await createAgent();
    agent.state.systemConfig.model = {
      ...(agent.state.systemConfig.model ?? {}),
      thinking_level: "high",
    };
    const ws = path.join(tmpRoot, "ws-inherit");
    await fs.mkdir(ws, { recursive: true });
    // The parent runs a NON-default model: the Project default (deepseek pair) must not leak in.
    const parent = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "anthropic",
    });
    const runner = lastSpawnedRunner();
    try {
      const child = await spawnedChildMeta(runner, {});
      expect(child.provider).toBe("anthropic");
      expect(child.model_id).toBe("claude-sonnet-4-6");
      expect(child.llm.thinkingLevel).toBe("high");
      // Workspace inheritance (behavior that predates model/thinking inheritance): locked here.
      expect(child.workspace).toBe(ws);
      // The spawn site marks the child's own session_meta as subagent-created — the single
      // source of truth the server derives from (its registration fallback cannot mask this).
      expect(child.source).toBe("subagent");
    } finally {
      parent.dispose();
    }
  });

  it("still honors an explicit (provider, model_id) pair over inheritance", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-inherit-explicit");
    await fs.mkdir(ws, { recursive: true });
    const parent = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "anthropic",
    });
    const runner = lastSpawnedRunner();
    try {
      const child = await spawnedChildMeta(runner, {
        modelId: "deepseek-v4-pro",
        provider: "deepseek",
      });
      expect(child.provider).toBe("deepseek");
      expect(child.model_id).toBe("deepseek-v4-pro");
      // Thinking level and workspace are inherited implicitly even with an explicit model.
      expect(child.llm.thinkingLevel).toBe("medium");
      expect(child.workspace).toBe(ws);
    } finally {
      parent.dispose();
    }
  });

  it("passes 'no thinking level' down when the parent has none (the child config never applies)", async () => {
    // helper_agent's own seeded config pins "medium". With the config chain always resolving
    // (Agent explicit > Project default_chat > built-in "medium"), a parent with NO level can
    // only come from the explicit tri-state null (an SDK caller suppressing the chain). That
    // null must reach the child as-is: falling back to the child's own config here was the
    // fallback hole — the child must show no level at all.
    await createAgent({ agentId: "helper_agent" });
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-inherit-none");
    await fs.mkdir(ws, { recursive: true });
    const parent = await agent.createSession({ workspaceDir: ws, thinkingLevel: null });
    const parentLLM = capturedLLMConfigs.list.at(-1)!;
    const runner = lastSpawnedRunner();
    try {
      expect("thinkingLevel" in parentLLM).toBe(false);
      const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
      // The tri-state null reached the child: no level at all, not helper_agent's "medium".
      expect("thinkingLevel" in child.llm).toBe(false);
    } finally {
      parent.dispose();
    }
  });

  it("rejects half a model reference at the runner layer (never completed from the parent's half)", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-inherit-half");
    await fs.mkdir(ws, { recursive: true });
    const parent = await agent.createSession({ workspaceDir: ws });
    const runner = lastSpawnedRunner();
    try {
      await expect(runner.spawn({ modelId: "claude-sonnet-4-6" })).rejects.toThrow(
        /must be given as a \(provider, model_id\) pair/,
      );
      await expect(runner.spawn({ provider: "anthropic" })).rejects.toThrow(
        /must be given as a \(provider, model_id\) pair/,
      );
    } finally {
      parent.dispose();
    }
  });

  it("makes a cross-agent child follow the parent session, not its own Agent config", async () => {
    // Seed the second Agent first (spawn verifies its system_config exists); its own seeded
    // config (thinking "medium") and the Project default model must both lose to the parent.
    await createAgent({ agentId: "helper_agent" });
    const agent = await createAgent();
    agent.state.systemConfig.model = {
      ...(agent.state.systemConfig.model ?? {}),
      thinking_level: "xhigh",
    };
    const ws = path.join(tmpRoot, "ws-inherit-cross");
    await fs.mkdir(ws, { recursive: true });
    const parent = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "anthropic",
    });
    const runner = lastSpawnedRunner();
    try {
      const child = await spawnedChildMeta(runner, { agentId: "helper_agent" });
      expect(child.provider).toBe("anthropic");
      expect(child.model_id).toBe("claude-sonnet-4-6");
      expect(child.llm.thinkingLevel).toBe("xhigh");
      expect(child.workspace).toBe(ws);
    } finally {
      parent.dispose();
    }
  });

  it("forwards the run input as an origin-tagged user message, after session_meta and before any model output", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-input-forward");
    await fs.mkdir(ws, { recursive: true });
    const parent = await agent.createSession({
      workspaceDir: ws,
      modelId: "claude-sonnet-4-6",
      provider: "anthropic",
    });
    const runner = lastSpawnedRunner();
    const handle = await runner.spawn({});
    try {
      // The child's user side has no other live source: a session's normal caller typed its
      // own input, but this caller is the PARENT — the frontend renders the child conversation
      // purely from these forwarded messages. (Regression: the panel showed no user messages
      // live, while a reload — child-Trace expansion — did show them.)
      const gen = handle.run({ prompt: "count the TODO items" });
      const first = (await gen.next()).value as { type: string };
      expect(first.type).toBe("session_meta");
      // The input yield precedes childSession.run: pulling it never issues an LLM request.
      const second = await gen.next();
      expect(second.done).toBe(false);
      const msg = second.value as {
        type: string;
        origin?: string[];
        payload: { type: string; role?: string; text?: string };
      };
      expect(msg.origin).toEqual([handle.sessionId]);
      expect(msg.type).toBe("model_msg");
      expect(msg.payload).toMatchObject({
        type: "text",
        role: "user",
        text: "count the TODO items",
      });
      await gen.return(undefined);
    } finally {
      handle.dispose();
      parent.dispose();
    }
  });
});

describe("Agent.createSession max output tokens (per-model cap wins over the Agent config)", () => {
  // Reads a constructed GenerativeModel's request config (private; runtime-accessible for assertion).
  const uniConfigOf = (llm: unknown) =>
    (llm as { uniConfig?: { max_tokens?: number } }).uniConfig ?? {};

  it("uses the entry's max_tokens in llmConfig, and inherits the seeded 32000 when unset", async () => {
    // A 32k-context local model: the seeded per-Agent default (32000 output tokens) cannot fit
    // into its window together with any prompt — the pinned per-model cap must win.
    await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
      provider: "custom",
      model_id: "local-32k",
      client_type: "openai",
      context_window: 32768,
      max_tokens: 8000,
    });
    const agent = await createAgent();
    expect(agent.state.systemConfig.model?.max_tokens).toBe(32000);
    const ws = path.join(tmpRoot, "ws-max-tokens");
    await fs.mkdir(ws, { recursive: true });

    const pinned = await agent.createSession({
      workspaceDir: ws,
      modelId: "local-32k",
      provider: "custom",
    });
    try {
      const llm = (pinned as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
      expect(uniConfigOf(llm).max_tokens).toBe(8000);
    } finally {
      pinned.dispose();
    }

    // An unannotated entry (the default model) inherits the Agent value, as before.
    const inherited = await agent.createSession({ workspaceDir: ws });
    try {
      const llm = (inherited as unknown as { engine: { deps: { llm: unknown } } }).engine.deps.llm;
      expect(uniConfigOf(llm).max_tokens).toBe(32000);
    } finally {
      inherited.dispose();
    }
  });

  it("meta requests keep their small budget, tightened when the per-model cap is even smaller", async () => {
    await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
      provider: "custom",
      model_id: "local-32k",
      client_type: "openai",
      max_tokens: 8000,
    });
    await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
      provider: "custom",
      model_id: "tiny-cap",
      client_type: "openai",
      max_tokens: 128,
    });
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-meta-cap");
    await fs.mkdir(ws, { recursive: true });

    // Ample per-model cap: the title one-shot keeps its own 300 budget (never raised to the cap).
    const ample = await agent.createSession({
      workspaceDir: ws,
      modelId: "local-32k",
      provider: "custom",
    });
    try {
      const bare = (ample as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
      expect(uniConfigOf(bare).max_tokens).toBe(300);
    } finally {
      ample.dispose();
    }

    // Cap pinned below the budget: the meta request must respect it too.
    const tiny = await agent.createSession({
      workspaceDir: ws,
      modelId: "tiny-cap",
      provider: "custom",
    });
    try {
      const bare = (tiny as unknown as { createBareLLM?: () => unknown }).createBareLLM?.();
      expect(uniConfigOf(bare).max_tokens).toBe(128);
    } finally {
      tiny.dispose();
    }
  });
});

describe("Agent.createSession vault injection", () => {
  it("injects vault key names (never values) into the assembled system prompt", async () => {
    // Write the Agent vault to disk first; createSession reads that Agent's own .vault.toml.
    await setVaultEntry(
      tmpRoot,
      DEFAULT_PROJECT_ID,
      DEFAULT_AGENT_ID,
      "VAULT_ONLY_KEY",
      "vault-secret-value",
    );
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-vault");
    await fs.mkdir(ws, { recursive: true });

    const session = await agent.createSession({ workspaceDir: ws });
    try {
      const meta = session.metaMessage.payload as { system_prompt: string };
      // The "# Vault" statement is part of the template body; key names are injected at the
      // placeholder as a `- KEY` list.
      expect(meta.system_prompt).toContain("# Vault");
      expect(meta.system_prompt).toContain("- VAULT_ONLY_KEY");
      // Values never enter the model context.
      expect(meta.system_prompt).not.toContain("vault-secret-value");
    } finally {
      session.dispose();
    }
  });

  it("keeps the vault statement but lists no keys when the Agent has no vault", async () => {
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-no-vault");
    await fs.mkdir(ws, { recursive: true });

    const session = await agent.createSession({ workspaceDir: ws });
    try {
      const meta = session.metaMessage.payload as { system_prompt: string };
      // No vault: the "# Vault" section statement is kept, and the
      // placeholder is replaced with an empty string, leaving no residue.
      expect(meta.system_prompt).toContain("# Vault");
      expect(meta.system_prompt).not.toContain("{{VAULT_KEYS}}");
      expect(meta.system_prompt).not.toContain("VAULT_ONLY_KEY");
    } finally {
      session.dispose();
    }
  });
});

describe("Agent.createSession skill metadata injection", () => {
  it("injects installed skill metadata lines (never bodies) into the assembled system prompt", async () => {
    await installSkill(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, {
      name: "demo-skill",
      content:
        "---\nname: demo-skill\ndescription: Demo skill for tests.\nversion: 1\nupdated: 2026-07-16\n---\n\nSKILL_BODY_NOT_IN_PROMPT\n",
    });
    const agent = await createAgent();
    const ws = path.join(tmpRoot, "ws-skills");
    await fs.mkdir(ws, { recursive: true });

    const session = await agent.createSession({ workspaceDir: ws });
    try {
      const meta = session.metaMessage.payload as { system_prompt: string };
      expect(meta.system_prompt).toContain("# Skills");
      expect(meta.system_prompt).toContain("- `demo-skill` — Demo skill for tests.");
      // Only metadata is injected; the model reads the body on demand.
      expect(meta.system_prompt).not.toContain("SKILL_BODY_NOT_IN_PROMPT");
      expect(meta.system_prompt).not.toContain("{{SKILL_METADATA}}");
    } finally {
      session.dispose();
    }
  });
});
