1.5.1: refined office, chat in the app language, current OpenAI models, own connector keys
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out. - Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language. - OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol. - Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar. - Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used. - Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors. - Per-agent model picker lists the provider's whole model list, live when connected. - Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id. - The stored "clock" theme reads back instead of falling to "system". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48dfddffef
commit
245ff66336
79 files changed
+1301
-1035
No files matched your search
@@ -138,6 +138,11 @@ describe("agent direct messages", () => {
|
||||
const assignment = h.connection.db.prepare("select space_id, body_md from chat_messages where org_id = ?").get(h.orgId) as { space_id: string; body_md: string };
|
||||
expect(assignment.space_id).toBe(spaces[0].id);
|
||||
expect(assignment.body_md).toContain("take task_id=");
|
||||
|
||||
h.organization.updateUiPreferences({ language: "zh" });
|
||||
dispatchAgentTool(deps, h.ctx, call("create_task", { title: "Ship docs", description: "d", assignee_agent_name: "Dana" }));
|
||||
const zh = h.connection.db.prepare("select body_md from chat_messages where org_id = ? and body_md like '%Ship docs%'").get(h.orgId) as { body_md: string };
|
||||
expect(zh.body_md).toContain("请接手 task_id=");
|
||||
});
|
||||
|
||||
test("send_message requires exactly one destination", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
@@ -9,6 +9,8 @@ import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn";
|
||||
import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants";
|
||||
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
|
||||
import { SkillService } from "../../src/main/services/skill-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||
import { buildAgentInstructions, type TaskExecutionAgentRow } from "../../src/main/services/task-prompts";
|
||||
import { AGENT_TOOLS } from "../../src/main/services/agent-tools";
|
||||
@@ -33,13 +35,13 @@ function agentRow(overrides: Partial<TaskExecutionAgentRow>): TaskExecutionAgent
|
||||
|
||||
describe("agent instructions", () => {
|
||||
test("first-hire guidance is one rule everywhere, and new leaders get a leader's voice", () => {
|
||||
const ceo = buildAgentInstructions({ agent: agentRow({ name: "CEO", role: "CEO", role_level: "c_suite", manager_agent_id: null }), companyName: "O", isCeo: true, hasReports: false, connectorNames: [], language: "en" });
|
||||
const ceo = buildAgentInstructions({ agent: agentRow({ name: "CEO", role: "CEO", role_level: "c_suite", manager_agent_id: null }), companyName: "O", isCeo: true, hasReports: false, connectorNames: [], language: "en", chatLanguage: "en" });
|
||||
const hireTool = AGENT_TOOLS.find((tool) => tool.name === "hire_agent");
|
||||
for (const text of [ceo, goalKickoffDescription("O", "Ship", "Ship it.", { roleLevel: "c_suite", hasReports: false }), hireTool?.description ?? ""]) {
|
||||
expect(text).toContain(CEO_FIRST_HIRES);
|
||||
}
|
||||
const voice = (role_level: string, hasReports: boolean) =>
|
||||
buildAgentInstructions({ agent: agentRow({ role_level }), companyName: "O", isCeo: false, hasReports, connectorNames: [], language: "en" });
|
||||
buildAgentInstructions({ agent: agentRow({ role_level }), companyName: "O", isCeo: false, hasReports, connectorNames: [], language: "en", chatLanguage: "en" });
|
||||
expect(voice("director", false)).toContain('Your department has no one in it yet: hire the managers (hire_agent, seniority "lead") and staff your department needs');
|
||||
expect(voice("lead", false)).toContain("Your team has no one in it yet: hire the staff your team needs (hire_agent)");
|
||||
expect(voice("lead", false)).toContain("You lead a team.");
|
||||
@@ -58,6 +60,7 @@ describe("agent instructions", () => {
|
||||
hasReports: false,
|
||||
connectorNames: ["GitHub", "Slack"],
|
||||
language: "en",
|
||||
chatLanguage: "en",
|
||||
});
|
||||
expect(text).toContain("every connected service");
|
||||
expect(text).toContain("grant_connector");
|
||||
@@ -77,6 +80,7 @@ describe("agent instructions", () => {
|
||||
hasReports: false,
|
||||
connectorNames: ["Stripe"],
|
||||
language: "en",
|
||||
chatLanguage: "en",
|
||||
});
|
||||
expect(text).toContain("Connected external services you carry in this run: Stripe");
|
||||
expect(text).toContain("composio_find_actions");
|
||||
@@ -93,31 +97,24 @@ describe("agent instructions", () => {
|
||||
hasReports: false,
|
||||
connectorNames: [],
|
||||
language: "en",
|
||||
chatLanguage: "en",
|
||||
});
|
||||
expect(text).toContain("You currently hold no connector grants");
|
||||
expect(text).toContain("message the CEO");
|
||||
expect(text).not.toContain("grant before you assign");
|
||||
});
|
||||
|
||||
test("Chinese app language directs agents to work in Chinese", () => {
|
||||
const zh = buildAgentInstructions({
|
||||
agent: agentRow({}),
|
||||
companyName: "OrbitOps",
|
||||
isCeo: false,
|
||||
hasReports: false,
|
||||
connectorNames: [],
|
||||
language: "zh",
|
||||
test("chat follows the app language; work follows the working language", () => {
|
||||
const text = (language: "en" | "zh", chatLanguage: "en" | "zh") => buildAgentInstructions({
|
||||
agent: agentRow({}), companyName: "OrbitOps", isCeo: false, hasReports: false, connectorNames: [], language, chatLanguage,
|
||||
});
|
||||
const mixed = text("en", "zh");
|
||||
expect(mixed).toContain("The working language is English");
|
||||
expect(mixed).toContain("聊天语言是简体中文");
|
||||
const zh = text("zh", "zh");
|
||||
expect(zh).toContain("公司的工作语言是简体中文");
|
||||
const en = buildAgentInstructions({
|
||||
agent: agentRow({}),
|
||||
companyName: "OrbitOps",
|
||||
isCeo: false,
|
||||
hasReports: false,
|
||||
connectorNames: [],
|
||||
language: "en",
|
||||
});
|
||||
expect(en).not.toContain("简体中文");
|
||||
expect(zh).not.toContain("The chat language is English");
|
||||
expect(text("en", "en")).not.toContain("简体中文");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,9 +175,11 @@ describe("agent instruction wiring", () => {
|
||||
await execution.runTask({ orgId, taskId });
|
||||
return capturedInstructions;
|
||||
};
|
||||
// The display language changes only what the operator sees.
|
||||
// The display language sets the chat language, not the working language.
|
||||
expect(await rerun()).toContain("The chat language is English");
|
||||
organization.updateUiPreferences({ language: "zh" });
|
||||
expect(await rerun()).toContain("The working language is English");
|
||||
expect(capturedInstructions).toContain("聊天语言是简体中文");
|
||||
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
|
||||
// The company's working language redirects its agents, and a task can keep its own.
|
||||
connection.db.prepare("update organizations set work_language = 'zh' where id = ?").run(orgId);
|
||||
@@ -188,6 +187,19 @@ describe("agent instruction wiring", () => {
|
||||
connection.db.prepare("update tasks set work_language = 'en' where id = ?").run(taskId);
|
||||
expect(await rerun()).toContain("The working language is English");
|
||||
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
|
||||
// A chat reply's house style follows the chat language.
|
||||
connection.db.prepare("update organizations set work_language = 'en' where id = ?").run(orgId);
|
||||
const houseStyle = vi.spyOn(SkillService.prototype, "hasRuntimeSkills");
|
||||
await rerun();
|
||||
expect(houseStyle.mock.lastCall?.[3]).toBe("en");
|
||||
const chat = new ChatService(connection.db);
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const spaceId = chat.ensureDirectSpace(orgId, { type: "employee", id: employeeId }, { type: "agent", id: agentId });
|
||||
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "ping", attachmentFileIds: [] });
|
||||
const wakeRun = connection.db.prepare("select id from task_runs where org_id = ? and trigger = 'chat_mention' and state = 'queued'").get(orgId) as { id: string };
|
||||
await execution.executeRun(orgId, wakeRun.id);
|
||||
expect(houseStyle.mock.lastCall?.[3]).toBe("zh");
|
||||
houseStyle.mockRestore();
|
||||
});
|
||||
|
||||
test("a new department head's task prompt tells them to staff their department, not to work as an IC", async () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { dropGoogleAntigravitySchema } from "../../src/main/database/migrations/111-drop-google-antigravity";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
|
||||
describe("dropping Google Antigravity", () => {
|
||||
test("removes its providers, frees agents and the auditor, erases its secrets and key", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-drop-ag-${randomUUID()}.db`));
|
||||
const db = connection.db;
|
||||
const organization = new OrganizationService(db);
|
||||
let orgId = "";
|
||||
let agentId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
db.exec(`
|
||||
create temp table ag as select * from providers where org_id = '${orgId}' and provider_key = 'openai';
|
||||
update ag set id = 'prov_ag', provider_key = 'google-antigravity', label = 'Google Antigravity', credential_id = 'cred_ag';
|
||||
`);
|
||||
db.prepare("insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) values ('cred_ag', ?, 'google-antigravity', 'oauth', x'0102', 'g', datetime('now'))").run(orgId);
|
||||
db.exec("insert into providers select * from ag");
|
||||
agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: "prov_ag", modelId: "gemini-3-flash", budgetCents: 1000, managerAgentId: null });
|
||||
db.prepare("update organizations set audit_provider_id = 'prov_ag' where id = ?").run(orgId);
|
||||
db.prepare("insert into integration_keys(name, ciphertext, updated_at) values ('google_antigravity_client_secret', x'01', datetime('now'))").run();
|
||||
db.prepare("update app_state set onboarding_draft_json = ? where id = 1").run(JSON.stringify({ step: "mission", companyName: "Next", providerKey: "google-antigravity", providerModel: "gemini-3-flash", providerBaseUrl: "" }));
|
||||
});
|
||||
|
||||
db.exec(dropGoogleAntigravitySchema);
|
||||
|
||||
expect(db.prepare("select count(*) as n from providers where provider_key = 'google-antigravity'").get()).toEqual({ n: 0 });
|
||||
expect(db.prepare("select model_provider_id, model_id from agents where id = ?").get(agentId)).toEqual({ model_provider_id: null, model_id: null });
|
||||
expect(db.prepare("select audit_provider_id from organizations where id = ?").get(orgId)).toEqual({ audit_provider_id: null });
|
||||
expect(db.prepare("select length(ciphertext) as bytes from credentials where id = 'cred_ag'").get()).toEqual({ bytes: 0 });
|
||||
expect(db.prepare("select count(*) as n from integration_keys").get()).toEqual({ n: 0 });
|
||||
expect(db.prepare("select count(*) as n from providers where provider_key = 'openai'").get()).toEqual({ n: 1 });
|
||||
const draft = JSON.parse((db.prepare("select onboarding_draft_json from app_state where id = 1").get() as { onboarding_draft_json: string }).onboarding_draft_json);
|
||||
expect(draft).toEqual({ step: "provider", companyName: "Next", providerKey: "openai", providerModel: "gpt-6.1-sol", providerBaseUrl: "" });
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { accumulateResponsesStream, parseResponsesOutput } from "../../src/main/
|
||||
import { accumulateChatStream, parseChatChoice } from "../../src/main/services/provider-tool-turns/chat-format";
|
||||
import { parseAnthropicContent } from "../../src/main/services/provider-tool-turns/anthropic-format";
|
||||
import { parseBedrockOutput } from "../../src/main/services/provider-tool-turns/bedrock-format";
|
||||
import { accumulateGeminiStream, parseGeminiResponse } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { parseGeminiResponse } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { mapAnthropicMessageStream, mapOpenAiChatCompletionStream, mapOpenAiResponseStream } from "../../src/main/services/provider-streaming";
|
||||
|
||||
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
|
||||
@@ -46,12 +46,10 @@ describe("a turn the provider cut short is never treated as finished", () => {
|
||||
expect(parseBedrockOutput({ stopReason: "end_turn", output: { message: { content: [{ text: "ok" }] } } }).text).toBe("ok");
|
||||
});
|
||||
|
||||
test("Gemini: MAX_TOKENS fails, a stream without finishReason fails, STOP passes", async () => {
|
||||
test("Gemini: MAX_TOKENS fails, STOP passes", () => {
|
||||
const parts = [{ text: "ok" }];
|
||||
expect(() => parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "MAX_TOKENS" }] })).toThrow("MAX_TOKENS");
|
||||
expect(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] }).text).toBe("ok");
|
||||
await expect(accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts } }] } }]))).rejects.toThrow("no stop reason");
|
||||
expect((await accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts }, finishReason: "STOP" }] } }]))).text).toBe("ok");
|
||||
});
|
||||
|
||||
test("streamed replies: a cut-off stop is an error, not a message_stop", async () => {
|
||||
|
||||
@@ -34,7 +34,6 @@ describe("integration key store", () => {
|
||||
expect(statuses.map((status) => [status.name, status.configured])).toEqual([
|
||||
["composio", false],
|
||||
["logo_dev", true],
|
||||
["google_antigravity_client_secret", false],
|
||||
]);
|
||||
expect(statuses[1].updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
connection.close();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { finishSprite, PixelGrid } from "../../src/renderer/src/pages/office/pixel-grid";
|
||||
import { personArt, ANCHOR } from "../../src/renderer/src/pages/office/person-art";
|
||||
import { appearanceFor, frameFor } from "../../src/renderer/src/pages/office/sprites";
|
||||
import { ditherLevels, sunPatches } from "../../src/renderer/src/pages/office/lighting";
|
||||
|
||||
const pixel = (grid: PixelGrid, x: number, y: number) => Array.from(grid.data.subarray((y * grid.w + x) * 4, (y * grid.w + x) * 4 + 4));
|
||||
|
||||
describe("office pixel art", () => {
|
||||
test("a finished sprite is rim-lit top-left, shaded bottom-right, and outlined in a dark tone of what it borders", () => {
|
||||
const grid = new PixelGrid(6, 6);
|
||||
grid.rect(1, 1, 4, 4, "#808080");
|
||||
finishSprite(grid);
|
||||
expect(pixel(grid, 1, 1)).toEqual([146, 146, 146, 255]);
|
||||
expect(pixel(grid, 4, 4)).toEqual([108, 108, 108, 255]);
|
||||
expect(pixel(grid, 2, 2)).toEqual([128, 128, 128, 255]);
|
||||
expect(pixel(grid, 0, 2)).toEqual([41, 41, 41, 255]);
|
||||
expect(pixel(grid, 0, 0)[3]).toBe(0);
|
||||
});
|
||||
|
||||
test("people stand on the anchor in every facing, and west mirrors east", () => {
|
||||
const look = appearanceFor("agent-7", false);
|
||||
for (const facing of ["south", "north", "east", "west"] as const) {
|
||||
const grid = personArt(look, { facing, step: -1, typing: -1, blink: false, carrying: null }, false);
|
||||
const feetRow = Array.from({ length: grid.w }, (_, x) => grid.alpha(x, ANCHOR.y - 1));
|
||||
expect(feetRow.some((alpha) => alpha > 0)).toBe(true);
|
||||
expect(Array.from({ length: grid.w }, (_, x) => grid.alpha(x, ANCHOR.y + 2)).every((alpha) => alpha === 0)).toBe(true);
|
||||
}
|
||||
const east = personArt(look, { facing: "east", step: 0, typing: -1, blink: false, carrying: "coffee" }, false);
|
||||
const west = personArt(look, { facing: "west", step: 0, typing: -1, blink: false, carrying: "coffee" }, false);
|
||||
expect(pixel(west, 3, 40)).toEqual(pixel(east, east.w - 4, 40));
|
||||
});
|
||||
|
||||
test("poses: four walk steps, typing beats only while working, a short blink", () => {
|
||||
const pose = { facing: "south" as const, walkPhase: 0, working: false, carrying: null, timeMs: 0, animSeed: 3 };
|
||||
expect(frameFor(pose).step).toBe(-1);
|
||||
expect(new Set([0.1, 1.7, 3.3, 4.9].map((walkPhase) => frameFor({ ...pose, walkPhase }).step))).toEqual(new Set([0, 1, 2, 3]));
|
||||
expect(frameFor(pose).typing).toBe(-1);
|
||||
expect(new Set([0, 180].map((timeMs) => frameFor({ ...pose, working: true, timeMs }).typing))).toEqual(new Set([0, 1]));
|
||||
const blinks = Array.from({ length: 400 }, (_, i) => frameFor({ ...pose, timeMs: i * 25 }).blink).filter(Boolean).length;
|
||||
expect(blinks).toBeGreaterThan(0);
|
||||
expect(blinks).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("office lighting", () => {
|
||||
test("the light map bands into whole levels with an ordered dither", () => {
|
||||
const data = new Uint8ClampedArray(4 * 4 * 4).fill(138);
|
||||
ditherLevels(data, 4);
|
||||
const levels = new Set(Array.from(data).filter((_, i) => i % 4 !== 3));
|
||||
expect(levels.size).toBe(2);
|
||||
for (const value of levels) expect(Math.round((value / 255) * 12) / 12).toBeCloseTo(value / 255, 2);
|
||||
});
|
||||
|
||||
test("sun falls through the windows by day, slanting with the hour, and not at night", () => {
|
||||
const glass = [{ x: 100, y: 8, w: 40, h: 20 }];
|
||||
expect(sunPatches(glass, new Date(2026, 9, 9, 23, 0))).toEqual([]);
|
||||
const morning = sunPatches(glass, new Date(2026, 9, 9, 9, 0))[0]!;
|
||||
const evening = sunPatches(glass, new Date(2026, 9, 9, 17, 0))[0]!;
|
||||
expect(morning[2]!.x).toBeGreaterThan(evening[2]!.x);
|
||||
expect(morning[2]!.y).toBeGreaterThan(morning[1]!.y);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,8 @@ import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog";
|
||||
import { ERASE_RETIRED_CREDENTIALS_SQL } from "../../src/main/database/migrations/079-erase-retired-credentials";
|
||||
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
|
||||
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
|
||||
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
|
||||
import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver";
|
||||
|
||||
@@ -33,43 +31,14 @@ function company(providerKey: string, secret: string, model: string) {
|
||||
}
|
||||
|
||||
describe("disconnecting a provider", () => {
|
||||
test("only Google Antigravity's sign-in can be revoked at the provider; the catalog says so", () => {
|
||||
expect(PROVIDER_CATALOG.filter((entry) => entry.revokesRemotely).map((entry) => entry.key)).toEqual(["google-antigravity"]);
|
||||
});
|
||||
|
||||
test("an API key is erased from this computer on disconnect, and a replaced key is erased too", async () => {
|
||||
vi.stubGlobal("fetch", async () => { throw new Error("no network call expected"); });
|
||||
const h = company("openai", "sk-first", "gpt-5.4");
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: "sk-second", model: "gpt-5.4" }));
|
||||
expect(h.ciphertexts()).toEqual([0, 9]);
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
h.connection.transaction(() => h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }));
|
||||
expect(h.ciphertexts()).toEqual([0, 0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ providerKey: "openai", remote: "not_supported" });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a Google sign-in is revoked at Google before it is erased here", async () => {
|
||||
const revoked: string[] = [];
|
||||
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
|
||||
revoked.push(`${url} ${String(init.body)}`);
|
||||
return new Response("", { status: 200 });
|
||||
});
|
||||
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
expect(revoked).toEqual(["https://oauth2.googleapis.com/revoke token=1%2F%2Frefresh"]);
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ remote: "revoked" });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("when Google does not confirm, the sign-in is still disconnected and erased here, and the failure is said plainly", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
|
||||
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
|
||||
await expect(h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }))
|
||||
.rejects.toThrow("Disconnected on this computer, but Google Antigravity did not confirm revoking the sign-in");
|
||||
expect(h.connection.db.prepare("select enabled, credential_id from providers where id = ?").get(h.providerId)).toEqual({ enabled: 0, credential_id: null });
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
expect(h.lastRevocation()).toMatchObject({ remote: "failed", remoteError: expect.stringContaining("400") });
|
||||
expect(h.lastRevocation()).toEqual({ providerKey: "openai" });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
@@ -77,43 +46,14 @@ describe("disconnecting a provider", () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const h = company("claude-pro", JSON.stringify({ access: "a", refresh: "r", expires: 0 }), "claude-sonnet-4-5");
|
||||
const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), integrationKeys: new IntegrationKeyStore(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) });
|
||||
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) });
|
||||
h.connection.transaction(() => h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }));
|
||||
answer(new Response(JSON.stringify({ access_token: "new", refresh_token: "new-r", expires_in: 3600 }), { status: 200 }));
|
||||
await expect(renewal).rejects.toThrow("disconnected or replaced while it was being renewed");
|
||||
expect(h.ciphertexts()).toEqual([0]);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a reconnect made while Google answers keeps the new connection and erases only the old sign-in", async () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
|
||||
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
|
||||
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
|
||||
answer(new Response("", { status: 200 }));
|
||||
await expect(disconnecting).rejects.toThrow("was reconnected while it was being disconnected");
|
||||
expect(h.connection.db.prepare("select enabled from providers where id = ?").get(h.providerId)).toEqual({ enabled: 1 });
|
||||
expect(h.ciphertexts()).toEqual([0, signIn("1//new").length]);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("a reconnect and a refused revocation at once are both reported", async () => {
|
||||
let answer: (response: Response) => void = () => {};
|
||||
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
|
||||
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
|
||||
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
|
||||
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
|
||||
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
|
||||
answer(new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
|
||||
const failure = (await disconnecting.catch((error: unknown) => error)) as Error;
|
||||
expect(failure.message).toContain("was reconnected while it was being disconnected");
|
||||
expect(failure.message).toContain("did not confirm revoking the sign-in (Google revocation failed: 400");
|
||||
expect(failure.message).toContain("revoke it in your account");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("credentials retired before this release are erased by the migration's statement", () => {
|
||||
const h = company("openai", "sk-old", "gpt-5.4");
|
||||
h.connection.db.prepare("update providers set credential_id = null where id = ?").run(h.providerId);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { refreshCodex } from "../../src/main/services/oauth/openai-codex-flow";
|
||||
import { refreshKimiCode } from "../../src/main/services/oauth/kimi-code-flow";
|
||||
import { postJson, ProviderHttpError } from "../../src/main/services/provider-http";
|
||||
import { accumulateResponsesStream } from "../../src/main/services/provider-tool-turns/responses-format";
|
||||
import { accumulateGeminiStream } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
@@ -67,8 +66,6 @@ describe("provider errors keep their status and machine code", () => {
|
||||
const codex = await accumulateResponsesStream(events({ type: "error", error: { type: "usage_limit_reached", message: "The usage limit has been reached" } })).catch((caught: unknown) => caught);
|
||||
expect(codex).toMatchObject({ status: null, code: "usage_limit_reached" });
|
||||
expect(classifyProviderFailure(codex, NOW).transient).toBe(true);
|
||||
const gemini = await accumulateGeminiStream(events({ error: { code: 503, message: "busy", status: "UNAVAILABLE" } })).catch((caught: unknown) => caught);
|
||||
expect(gemini).toMatchObject({ status: 503, code: "UNAVAILABLE" });
|
||||
});
|
||||
|
||||
test("a streamed reply's error event keeps the provider's code on the error and the request", async () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
|
||||
import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow";
|
||||
import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-code-flow";
|
||||
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
|
||||
import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow";
|
||||
import { generatePkce } from "../../src/main/services/oauth/pkce";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
|
||||
@@ -80,28 +79,6 @@ describe("openai codex account id extraction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("antigravity project discovery", () => {
|
||||
test("reads a string or object project id", () => {
|
||||
expect(readProjectId({ cloudaicompanionProject: "proj-a" })).toBe("proj-a");
|
||||
expect(readProjectId({ cloudaicompanionProject: { id: "proj-b" } })).toBe("proj-b");
|
||||
});
|
||||
|
||||
test("returns null when no project is present", () => {
|
||||
expect(readProjectId({})).toBeNull();
|
||||
expect(readProjectId({ cloudaicompanionProject: {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("antigravity oauth configuration", () => {
|
||||
test("passes a saved client secret through", () => {
|
||||
expect(requireAntigravityClientSecret("live-secret")).toBe("live-secret");
|
||||
});
|
||||
|
||||
test("requires a client secret saved in Settings", () => {
|
||||
expect(() => requireAntigravityClientSecret(null)).toThrow("Settings > Integrations");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pkce generation", () => {
|
||||
test("produces an S256 challenge for the verifier", () => {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
@@ -117,7 +94,6 @@ describe("oauth credential parsing", () => {
|
||||
test("a stored sign-in is accepted only with what a run needs to use and renew it", () => {
|
||||
expect(providerAdapters["github-copilot"].parseAuthProfile(signIn())).toEqual({ type: "access_token", accessToken: "live_token" });
|
||||
expect(providerAdapters["gpt-account"].parseAuthProfile(signIn({ accountId: "acct_1" }))).toEqual({ type: "account_token", accessToken: "live_token" });
|
||||
expect(providerAdapters["google-antigravity"].parseAuthProfile(signIn({ projectId: "proj-1" }))).toEqual({ type: "access_token", accessToken: "live_token" });
|
||||
});
|
||||
|
||||
test("a pasted token, or a sign-in missing its refresh token, expiry or provider extras, is refused at save time", () => {
|
||||
@@ -125,7 +101,6 @@ describe("oauth credential parsing", () => {
|
||||
expect(() => providerAdapters["claude-pro"].parseAuthProfile(JSON.stringify({ access: "a", expires: 1 }))).toThrow("missing its refresh token");
|
||||
expect(() => providerAdapters["kimi-code"].parseAuthProfile(JSON.stringify({ access: "a", refresh: "r" }))).toThrow("missing its expiry");
|
||||
expect(() => providerAdapters["gpt-account"].parseAuthProfile(signIn())).toThrow("missing its ChatGPT account id");
|
||||
expect(() => providerAdapters["google-antigravity"].parseAuthProfile(signIn())).toThrow("missing its Google Cloud project id");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { AgentTurnMessage } from "../../src/main/services/provider-tool-tur
|
||||
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "../../src/main/services/provider-tool-turns/responses-format";
|
||||
import { accumulateChatStream, parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format";
|
||||
import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format";
|
||||
import { accumulateGeminiStream, antigravityRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format";
|
||||
import { parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { signAwsRequest } from "../../src/main/services/provider-tool-turns/aws-sigv4";
|
||||
@@ -19,7 +18,6 @@ import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
|
||||
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
|
||||
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
|
||||
|
||||
const CONVO: AgentTurnMessage[] = [
|
||||
{ role: "user", text: "do it" },
|
||||
@@ -135,40 +133,6 @@ describe("anthropic encoding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("antigravity (gemini) encoding", () => {
|
||||
test("wraps the request with the Antigravity system instruction and tool declarations", () => {
|
||||
const body = antigravityRequestBody({ model: "claude-sonnet-4-5", projectId: "proj", instructions: "be helpful", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-1" });
|
||||
expect(body.project).toBe("proj");
|
||||
expect(body.requestType).toBe("agent");
|
||||
expect(body.userAgent).toBe("antigravity");
|
||||
const request = body.request as JsonRecord;
|
||||
const system = request.systemInstruction as { role: string; parts: Array<{ text: string }> };
|
||||
expect(system.role).toBe("user");
|
||||
expect(system.parts[0].text).toContain("You are Antigravity");
|
||||
expect(system.parts[system.parts.length - 1].text).toBe("be helpful");
|
||||
// claude-* models use the legacy `parameters` field
|
||||
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parameters: { type: "object" } }] }]);
|
||||
});
|
||||
|
||||
test("gemini models use parametersJsonSchema and omit the tool-call id", () => {
|
||||
const body = antigravityRequestBody({ model: "gemini-3.1-pro-high", projectId: "proj", instructions: null, messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-2" });
|
||||
const request = body.request as JsonRecord;
|
||||
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
|
||||
const contents = request.contents as Array<{ role: string; parts: Array<JsonRecord> }>;
|
||||
const assistant = contents.find((c) => c.role === "model");
|
||||
const fnCall = assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord;
|
||||
expect(fnCall.id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accumulates streamed candidates", async () => {
|
||||
const result = await accumulateGeminiStream(stream([
|
||||
{ response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] }, finishReason: "STOP" }] } },
|
||||
]));
|
||||
expect(result.text).toBe("hello");
|
||||
expect(result.toolCalls).toEqual([{ id: "g1", name: "f", arguments: { x: 1 } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bedrock converse encoding", () => {
|
||||
test("encodes toolUse/toolResult and the tool config", () => {
|
||||
expect(toBedrockMessages(CONVO)).toEqual([
|
||||
@@ -190,9 +154,11 @@ describe("bedrock converse encoding", () => {
|
||||
|
||||
describe("vertex (gemini) encoding", () => {
|
||||
test("builds a generateContent body with tools and parses the response", () => {
|
||||
const body = vertexRequestBody({ model: "gemini-2.5-pro", instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
|
||||
const body = vertexRequestBody({ instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
|
||||
expect((body.systemInstruction as JsonRecord).parts).toEqual([{ text: "sys" }]);
|
||||
expect(body.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
|
||||
const assistant = (body.contents as Array<{ role: string; parts: JsonRecord[] }>).find((c) => c.role === "model");
|
||||
expect((assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord).id).toBeUndefined();
|
||||
const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] }, finishReason: "STOP" }] });
|
||||
expect(parsed).toMatchObject({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
@@ -214,7 +180,7 @@ describe("aws sigv4", () => {
|
||||
|
||||
describe("tool-turn router", () => {
|
||||
test("supports every agent-capable provider (incl. cloud and custom) and rejects unknown ones", () => {
|
||||
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
|
||||
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
|
||||
expect(supportsToolTurn(key)).toBe(true);
|
||||
expect(typeof selectToolTurn(key)).toBe("function");
|
||||
}
|
||||
@@ -229,7 +195,6 @@ describe("credential resolver", () => {
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault);
|
||||
const authBroker = new AuthBrokerService(connection.db, testVault);
|
||||
const integrationKeys = new IntegrationKeyStore(connection.db, testVault);
|
||||
let orgId = "";
|
||||
let openaiId = "";
|
||||
let gptAccountId = "";
|
||||
@@ -243,32 +208,14 @@ describe("credential resolver", () => {
|
||||
});
|
||||
|
||||
return (async () => {
|
||||
const apiKeyCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) });
|
||||
const apiKeyCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) });
|
||||
expect(apiKeyCred).toEqual({ apiKey: "sk-live" });
|
||||
const oauthCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) });
|
||||
const oauthCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) });
|
||||
expect(oauthCred.accessToken).toBe("tok");
|
||||
expect(oauthCred.accountId).toBe("acct_1");
|
||||
connection.close();
|
||||
})();
|
||||
});
|
||||
|
||||
test("an expired Antigravity credential without a saved client secret points to Settings", async () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault);
|
||||
const authBroker = new AuthBrokerService(connection.db, testVault);
|
||||
const integrationKeys = new IntegrationKeyStore(connection.db, testVault);
|
||||
let orgId = "";
|
||||
let antigravityId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
antigravityId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'google-antigravity'").get(orgId) as { id: string }).id;
|
||||
providers.saveCredential({ orgId, providerId: antigravityId, secret: JSON.stringify({ access: "old", refresh: "r", expires: 0, projectId: "p" }), model: "gemini-3-pro" });
|
||||
});
|
||||
await expect(resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, antigravityId) }))
|
||||
.rejects.toThrow("Settings > Integrations");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("responses tool definitions", () => {
|
||||
@@ -306,7 +253,7 @@ describe("mid-run follow-up text rides on the tool-result turn", () => {
|
||||
role: "user",
|
||||
content: [{ toolResult: { toolUseId: "c1", content: [{ text: "ok" }] } }, { text: "New: stop" }],
|
||||
});
|
||||
const gemini = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: convo, tools: [] }) as { contents: Array<{ role: string; parts: unknown[] }> };
|
||||
const gemini = vertexRequestBody({ instructions: null, messages: convo, tools: [] }) as { contents: Array<{ role: string; parts: unknown[] }> };
|
||||
expect(gemini.contents.at(-1)).toEqual({
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "f", response: { output: "ok" } } }, { text: "New: stop" }],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } fro
|
||||
import { accumulateChatStream, parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format";
|
||||
import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format";
|
||||
import { parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format";
|
||||
import { accumulateGeminiStream, parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
|
||||
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
|
||||
for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) };
|
||||
@@ -53,10 +53,8 @@ describe("reasoning survives tool turns", () => {
|
||||
|
||||
test("Gemini: thought signatures on parts go back, streamed or not", async () => {
|
||||
const parts = [{ text: "plan", thought: true }, { functionCall: { name: "f", args: {} }, thoughtSignature: "SIG" }];
|
||||
const vertex = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] })), tools: [] });
|
||||
const vertex = vertexRequestBody({ instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] })), tools: [] });
|
||||
expect((vertex.contents as JsonRecord[])[1]).toEqual({ role: "model", parts });
|
||||
const streamed = await accumulateGeminiStream(stream([...parts.map((part) => ({ response: { candidates: [{ content: { parts: [part] } }] } })), { response: { candidates: [{ finishReason: "STOP" }] } }]));
|
||||
expect(streamed.replay).toEqual({ format: "gemini", content: parts });
|
||||
});
|
||||
|
||||
test("Chat: streamed reasoning goes back under the field it came in, and OpenRouter details are merged into whole entries", async () => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { createRunMessagePoller } from "../../src/main/services/chat-wake-context";
|
||||
|
||||
describe("the mid-run inbox", () => {
|
||||
test("injects each new message once, even after another company's deletion frees message row numbers", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-inbox-${randomUUID()}.db`));
|
||||
const db = connection.db;
|
||||
const organization = new OrganizationService(db);
|
||||
const chat = new ChatService(db);
|
||||
const company = (name: string) => {
|
||||
const orgId = organization.createOrganization({ organizationName: name, employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const agentId = organization.createAgent({ orgId, name: "Dana", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
|
||||
const employeeId = (db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const spaceId = chat.ensureDirectSpace(orgId, { type: "employee", id: employeeId }, { type: "agent", id: agentId });
|
||||
const say = (bodyMd: string, threadId: string | null) => {
|
||||
const messageId = chat.sendMessage({ orgId, spaceId, threadId, senderType: "employee", senderId: employeeId, bodyMd, attachmentFileIds: [] });
|
||||
return (db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }).thread_id;
|
||||
};
|
||||
return { orgId, agentId, say };
|
||||
};
|
||||
const ours = connection.transaction(() => company("Ours"));
|
||||
const threadId = ours.say("Start.", null);
|
||||
const theirs = connection.transaction(() => company("Theirs"));
|
||||
for (let i = 0; i < 5; i++) theirs.say(`Noise ${i}`, null);
|
||||
const injected: string[] = [];
|
||||
const poll = createRunMessagePoller(db, { orgId: ours.orgId, agentId: ours.agentId, sourceThreadId: threadId, runStartedAt: new Date(0).toISOString(), onInjected: (entries) => injected.push(...entries.map((entry) => entry.id)) });
|
||||
expect(poll()).toBeNull();
|
||||
connection.transaction(() => organization.deleteOrganization(theirs.orgId));
|
||||
ours.say("Correction.", threadId);
|
||||
expect(poll()).toContain("Correction.");
|
||||
expect(poll()).toBeNull();
|
||||
expect(injected).toHaveLength(1);
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,6 @@ describe("every agent turn has the same output cap and a wait that fits how it i
|
||||
const { body } = await capture(key);
|
||||
expect([key, body[field]]).toEqual([key, OUTPUT_RESERVE_TOKENS]);
|
||||
}
|
||||
expect(outputCap((await capture("google-antigravity", "gemini-3-pro")).body)).toBe(OUTPUT_RESERVE_TOKENS);
|
||||
expect(outputCap((await capture("vertex-ai", "gemini-3-pro")).body)).toBe(OUTPUT_RESERVE_TOKENS);
|
||||
expect(outputCap((await capture("aws-bedrock", "anthropic.claude-sonnet-4-5")).body)).toBe(OUTPUT_RESERVE_TOKENS);
|
||||
});
|
||||
@@ -64,7 +63,7 @@ describe("every agent turn has the same output cap and a wait that fits how it i
|
||||
const { streamed, timeouts } = await capture(key);
|
||||
expect([key, streamed, timeouts?.headersTimeoutMs]).toEqual([key, false, 300_000]);
|
||||
}
|
||||
for (const key of ["xai", "kimi-code", "gpt-account", "google-antigravity"]) {
|
||||
for (const key of ["xai", "kimi-code", "gpt-account"]) {
|
||||
const { streamed, timeouts } = await capture(key);
|
||||
expect([key, streamed, timeouts?.headersTimeoutMs]).toEqual([key, true, undefined]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
|
||||
describe("ui preferences", () => {
|
||||
test("every saved theme reads back as saved, the clock theme included; an unknown one fails loudly", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-ui-prefs-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
connection.transaction(() => organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }));
|
||||
for (const theme of ["system", "light", "dark", "clock"] as const) {
|
||||
organization.updateUiPreferences({ theme });
|
||||
expect(snapshot.load().uiPreferences.theme).toBe(theme);
|
||||
}
|
||||
connection.db.prepare("update app_state set theme = 'sepia' where id = 1").run();
|
||||
expect(() => snapshot.load()).toThrow('Stored theme "sepia" is not a known theme.');
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user