agent-paths.test.ts5.3 KBView on GitHub
/**
 * The client's copy of the agent path convention.
 *
 * These two functions are a deliberate re-implementation of
 * `agentNamespacePath`/`agentMemoryDirPath` in apps/server — the server modules that own
 * them drag in the document service and the DB layer, so the browser mints the strings
 * itself. A duplicated convention is only safe while something pins the exact shape, and
 * this is that something: if the server's minting ever changes, these assertions are what
 * fail rather than a silently empty file tree.
 *
 * The scope parameter is what lets the agent workspace render at a DEAL rather than at
 * the user (apps/mail/docs/meeting-tab.md phase 1). Its default is the whole compatibility
 * story, so it is asserted first and separately.
 */
import {
  agentConfigDirPath,
  agentDefaultFilePath,
  agentMemoryDirPath,
  agentNamespacePath,
} from '@/modules/agents/utils/agent-paths';

const AGENT = 'b41c0000-0000-0000-0000-000000000001';
const CONVERSATION = 'e8c4ead2-ed2e-471a-a03c-b73bfd2987e6';

describe('agentNamespacePath', () => {
  it('defaults to the user scope, so /agents/:agentId is unchanged', () => {
    expect(agentNamespacePath(AGENT)).toBe(`user/agent-${AGENT}`);
  });

  it('mints the same string when the user scope is passed explicitly', () => {
    expect(agentNamespacePath(AGENT, { type: 'user' })).toBe(agentNamespacePath(AGENT));
  });

  it('roots the namespace in the deal under a conversation scope', () => {
    expect(agentNamespacePath(AGENT, { type: 'conversation', conversationId: CONVERSATION })).toBe(
      `conversation/${CONVERSATION}/agent-${AGENT}`,
    );
  });

  it('matches the paths the meeting-prep agent already writes', () => {
    // Not a restatement of the line above: this is the shape `automations.ts` instructs
    // the agent to archive under, and the shape `resolveOpenDoc`'s `__meeting_prep__`
    // branch resolves. The tree has to root at exactly this prefix or it lists nothing.
    const namespace = agentNamespacePath(AGENT, {
      type: 'conversation',
      conversationId: CONVERSATION,
    });
    expect(`${namespace}/overview`).toBe(
      `conversation/${CONVERSATION}/agent-${AGENT}/overview`,
    );
    expect(`${namespace}/archives/2026-08-13`).toBe(
      `conversation/${CONVERSATION}/agent-${AGENT}/archives/2026-08-13`,
    );
  });
});

describe('agentMemoryDirPath', () => {
  it('defaults to the user scope', () => {
    expect(agentMemoryDirPath(AGENT)).toBe(`user/agent-${AGENT}/memory`);
  });

  it('follows the scope it is given, rather than pinning memory to the user', () => {
    expect(agentMemoryDirPath(AGENT, { type: 'conversation', conversationId: CONVERSATION })).toBe(
      `conversation/${CONVERSATION}/agent-${AGENT}/memory`,
    );
  });

  it('is always the namespace plus /memory, in every scope', () => {
    // The Output tab excludes exactly this path from the tree, by string equality. If the
    // two helpers ever disagreed about a scope, memory would show up as output.
    for (const scope of [
      { type: 'user' } as const,
      { type: 'conversation', conversationId: CONVERSATION } as const,
    ]) {
      expect(agentMemoryDirPath(AGENT, scope)).toBe(`${agentNamespacePath(AGENT, scope)}/memory`);
    }
  });
});

/**
 * The ORG scope — an agent whose document declares `namespace: org` keeps ONE folder for the
 * whole team instead of a private copy per teammate.
 *
 * Asserted here as well as on the server because these two implementations are deliberately
 * separate (the server modules pull in the DB layer, which the browser must not), and a mirror
 * that drifts renders the tab against a path that holds none of the agent's files — an empty
 * Output tab for an agent that has written plenty, with no error anywhere.
 */
describe('the org scope', () => {
  it('mints one folder for the team, under organisation/', () => {
    expect(agentNamespacePath(AGENT, { type: 'org' })).toBe(`organisation/agent-${AGENT}`);
  });

  it('is a different folder from the same agent’s user namespace', () => {
    expect(agentNamespacePath(AGENT, { type: 'org' })).not.toBe(agentNamespacePath(AGENT));
  });

  it('carries the memory folder with it', () => {
    expect(agentMemoryDirPath(AGENT, { type: 'org' })).toBe(`organisation/agent-${AGENT}/memory`);
  });

  it('roots the config folder there too', () => {
    expect(agentConfigDirPath(AGENT, { type: 'org' })).toBe(`organisation/agent-${AGENT}/config`);
  });
});

describe('agentDefaultFilePath', () => {
  it('joins the agent’s declared file onto its folder, per scope', () => {
    expect(agentDefaultFilePath(AGENT, 'overview')).toBe(`user/agent-${AGENT}/overview`);
    expect(agentDefaultFilePath(AGENT, 'outputs/weekly', { type: 'org' })).toBe(
      `organisation/agent-${AGENT}/outputs/weekly`,
    );
    expect(
      agentDefaultFilePath(AGENT, 'overview', { type: 'conversation', conversationId: 'c1' }),
    ).toBe(`conversation/c1/agent-${AGENT}/overview`);
  });

  it('opens nothing for `none` (null) and nothing while the agent is still loading', () => {
    // `null` is the agent's choice; `undefined` is "not answered yet". The browser opens
    // `autoOpenPath` ONCE per mount, so a guess before the answer cannot be withdrawn.
    expect(agentDefaultFilePath(AGENT, null)).toBeUndefined();
    expect(agentDefaultFilePath(AGENT, undefined)).toBeUndefined();
  });
});