* Pick the voice from a carousel in the app language, local voice only Voice settings now list only the voices that speak the app language (German shows German voices, English shows English ones) as a horizontal carousel: one card per voice, a play button next to the name, tap to make it the active voice. Changing the app language moves the saved voice to that language. The hosted voice providers (ElevenLabs, OpenAI, Cartesia, Fish Audio) are removed; Kokoro on the server is the only voice. E2E runs against a stub Kokoro server so the carousel is covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 * test(e2e): measure the workspace panel width after its transition settles Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57 --------- Co-authored-by: Claude <noreply@anthropic.com>
220 lines
9.4 KiB
TypeScript
220 lines
9.4 KiB
TypeScript
import { serializeModelSecret } from "@rakazo/adapters";
|
|
import type { Actor } from "@rakazo/contracts";
|
|
import { AI_DISCLOSURE_VERSION } from "@rakazo/contracts";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./thread-target.js", () => ({
|
|
resolveThreadTarget: async () => ({ kind: "bot", botId: "bot" }),
|
|
}));
|
|
|
|
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
|
import type { RouterDeps } from "./router.js";
|
|
|
|
function setup() {
|
|
const upsert = vi.fn(async () => ({}));
|
|
const deps = {
|
|
env: { agentRuntime: "scripted" },
|
|
prisma: {
|
|
spaceModelPreference: { findMany: vi.fn(async () => []), findFirst: vi.fn(async () => null) },
|
|
spaceModelTier: { findMany: vi.fn(async () => []) },
|
|
secret: { findMany: vi.fn(async () => []) },
|
|
bot: { findMany: vi.fn(async () => []) },
|
|
spaceVoicePreference: {
|
|
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
|
|
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
|
|
},
|
|
spaceMemoryConfig: {
|
|
findUnique: vi.fn(async () => null),
|
|
count: vi.fn(async () => 0),
|
|
},
|
|
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
|
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
|
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
|
|
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
|
|
$transaction: vi.fn(async (queries) => Promise.all(queries)),
|
|
},
|
|
} as unknown as RouterDeps;
|
|
return { deps, upsert, actor: { userId: "user", spaceId: "space" } as Actor };
|
|
}
|
|
describe("consent grants", () => {
|
|
it("includes reachable helper models and cloud services for a targeted send", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.agentRuntime = "pi";
|
|
deps.cloudAgent = {
|
|
key: "test",
|
|
spaceId: actor.spaceId,
|
|
provider: { describe: () => ({ id: "cursor", capabilities: { offline: false } }) },
|
|
} as RouterDeps["cloudAgent"];
|
|
vi.mocked(deps.prisma.spaceModelPreference.findMany).mockResolvedValue([
|
|
{
|
|
modelId: "helper-model",
|
|
isDefault: false,
|
|
credential: { provider: "anthropic", secretId: "test-secret" },
|
|
},
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
expect(status.recipients.map((recipient) => recipient.name)).toEqual(
|
|
expect.arrayContaining(["Anthropic", "Cursor"]),
|
|
);
|
|
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
|
expect(deps.prisma.bot.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: expect.objectContaining({ id: { in: ["bot"] } }) }),
|
|
);
|
|
});
|
|
it("discloses the model a bot's tier maps to on a router endpoint", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.agentRuntime = "pi";
|
|
deps.secrets = {
|
|
load: vi.fn(() =>
|
|
serializeModelSecret({ kind: "openai_compatible", baseUrl: "http://127.0.0.1:8080/v1" }),
|
|
),
|
|
} as unknown as RouterDeps["secrets"];
|
|
vi.mocked(deps.prisma.spaceModelTier.findMany).mockResolvedValue([
|
|
{
|
|
tier: "code",
|
|
credentialId: "router",
|
|
modelId: "router-code",
|
|
credential: {
|
|
id: "router",
|
|
userId: actor.userId,
|
|
provider: "openai-compatible",
|
|
label: "Router",
|
|
secretId: "router-secret",
|
|
supportsImages: false,
|
|
createdAt: new Date(0),
|
|
updatedAt: new Date(0),
|
|
preferences: [],
|
|
},
|
|
},
|
|
] as never);
|
|
vi.mocked(deps.prisma.bot.findMany).mockResolvedValue([
|
|
{ modelProvider: null, modelId: null, thinkingLevel: null, modelTier: "code" },
|
|
] as never);
|
|
vi.mocked(deps.prisma.secret.findMany).mockResolvedValue([
|
|
{ id: "router-secret", ciphertext: "fake-ciphertext" },
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
expect(status.recipients).toEqual([
|
|
expect.objectContaining({
|
|
name: expect.stringContaining("http://127.0.0.1:8080"),
|
|
detail: "Model: router-code.",
|
|
}),
|
|
]);
|
|
});
|
|
it("discloses only the selected voice provider before a voice action", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
|
|
{ credential: { provider: "kokoro" } },
|
|
{ credential: { provider: "scripted" } },
|
|
] as never);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toBe("Local voice");
|
|
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
|
|
});
|
|
it("discloses memory provider details to space owners", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceMemoryConfig.findUnique).mockResolvedValue({
|
|
provider: "supermemory",
|
|
settings: { baseUrl: "http://127.0.0.1:8123" },
|
|
} as never);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toContain("Supermemory");
|
|
expect(status.recipients[0]?.name).toContain("127.0.0.1:8123");
|
|
expect(deps.prisma.spaceMemoryConfig.count).not.toHaveBeenCalled();
|
|
});
|
|
it("redacts space memory provider details for non-owner members", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.spaceMember.findUnique).mockResolvedValue({ role: "member" } as never);
|
|
vi.mocked(deps.prisma.spaceMemoryConfig.count).mockResolvedValue(1);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["memory"] });
|
|
expect(status.recipients).toHaveLength(1);
|
|
expect(status.recipients[0]?.name).toBe("Space memory");
|
|
expect(JSON.stringify(status.recipients)).not.toContain("127.0.0.1");
|
|
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
|
|
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
it("discloses the deployment's media servers before model use, keyed by server", async () => {
|
|
const { deps, actor } = setup();
|
|
const settings = {
|
|
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
|
|
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
|
|
};
|
|
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
|
id: "media",
|
|
ciphertext: "fake-media-ciphertext",
|
|
} as never);
|
|
deps.secrets = {
|
|
load: vi.fn(() => JSON.stringify(settings)),
|
|
} as unknown as RouterDeps["secrets"];
|
|
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
|
|
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
|
|
"ComfyUI (http://127.0.0.1:8188)",
|
|
"Transcription (http://127.0.0.1:8080)",
|
|
]);
|
|
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
|
|
expect(JSON.stringify(status)).not.toContain("comfy-key");
|
|
// The local voice cannot transcribe, so dictation also goes to the transcription server.
|
|
expect(
|
|
(await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients.map(
|
|
(recipient) => recipient.name,
|
|
),
|
|
).toEqual(["Local voice", "Transcription (http://127.0.0.1:8080)"]);
|
|
});
|
|
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
|
|
const { deps, actor } = setup();
|
|
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
|
|
id: "media",
|
|
ciphertext: "fake-media-ciphertext-voice",
|
|
} as never);
|
|
deps.secrets = {
|
|
load: vi.fn(() =>
|
|
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
|
|
),
|
|
} as unknown as RouterDeps["secrets"];
|
|
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
|
|
credential: { provider: "kokoro" },
|
|
voiceId: "af_heart",
|
|
} as never);
|
|
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
|
|
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
|
|
["Local voice", "voice"],
|
|
["Transcription (http://127.0.0.1:8080)", "voice"],
|
|
]);
|
|
});
|
|
it("returns an operator policy without requiring a hosted provider", async () => {
|
|
const { deps, actor } = setup();
|
|
deps.env.privacyPolicyUrl = "https://example.com/privacy";
|
|
expect((await aiConsentStatus(deps, actor)).privacyUrl).toBe("https://example.com/privacy");
|
|
});
|
|
it("rejects stale disclosures, unknown recipients, and a changed account or Space", async () => {
|
|
const { deps, actor, upsert } = setup();
|
|
const current = await aiConsentStatus(deps, actor);
|
|
const input = {
|
|
scope: current.scope,
|
|
version: current.version,
|
|
keys: [current.recipients[0]!.key],
|
|
};
|
|
for (const invalid of [
|
|
{ ...input, version: "old" },
|
|
{ ...input, scope: "different-account" },
|
|
{ ...input, keys: ["unknown"] },
|
|
]) {
|
|
await expect(allowAiConsent(deps, actor, invalid)).rejects.toThrow("changed");
|
|
}
|
|
expect(upsert).not.toHaveBeenCalled();
|
|
await allowAiConsent(deps, actor, input);
|
|
expect(upsert).toHaveBeenCalledExactlyOnceWith(
|
|
expect.objectContaining({
|
|
create: {
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
recipientKey: input.keys[0],
|
|
version: AI_DISCLOSURE_VERSION,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|