diff --git a/README.md b/README.md index fafeccc1..c7406b4a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ The app ships with no keys. Add your own in **Settings > Integrations**: |---|---|---| | Composio API key | Connectors page and agent connector tools | platform.composio.dev | | logo.dev publishable key | Connector logos | logo.dev | -| Google Antigravity OAuth client secret | Google Antigravity sign-in | Google | Model provider keys and sign-ins live in **Settings > Providers**. All keys are encrypted with the OS keychain. diff --git a/package.json b/package.json index 51bbb9cf..f1834ef8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcompany-new", - "version": "1.5.0", + "version": "1.5.1", "private": true, "type": "module", "description": "Enterprise Electron control plane for AI agent companies.", diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 3b602b9c..c9a431b2 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -65,7 +65,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const chat = new ChatService(deps.connection.db); const providers = new ProviderService(deps.connection.db); const integrationKeys = new IntegrationKeyStore(deps.connection.db); - const providerOAuth = new ProviderOAuthService(integrationKeys); + const providerOAuth = new ProviderOAuthService(); const skills = new SkillService(deps.connection.db, deps.artifactRoot); const announceChange = coalesce(() => { try { @@ -261,17 +261,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); handle("setProviderModel", (input) => mutate(() => setProviderModel(deps.connection.db, input))); - handle("revokeProviderCredential", async (input) => { - try { - await providers.revokeCredential(input); - notifyDataChanged(); - return ok(snapshot.load()); - } catch (error) { - // The local disconnect has happened even when the provider refused the revocation. - notifyDataChanged(); - return fail("provider_revoke_failed", error instanceof Error ? error.message : String(error)); - } - }); + handle("revokeProviderCredential", (input) => mutate(() => providers.revokeCredential(input))); handle("updateProviderPolicy", (input) => mutate(() => providers.updatePolicy(input))); handle("setProviderPrices", (input) => mutate(() => providers.setPrices(input))); handle("setGoalAuditor", (input) => mutate(() => setGoalAuditor(deps.connection.db, input))); diff --git a/src/main/database/migrations/111-drop-google-antigravity.ts b/src/main/database/migrations/111-drop-google-antigravity.ts new file mode 100644 index 00000000..3a9e0b37 --- /dev/null +++ b/src/main/database/migrations/111-drop-google-antigravity.ts @@ -0,0 +1,13 @@ +// Google Antigravity is no longer offered; its rows, secrets and key go. +export const dropGoogleAntigravitySchema = ` + update agents set model_provider_id = null, model_id = null + where model_provider_id in (select id from providers where provider_key = 'google-antigravity'); + update organizations set audit_provider_id = null + where audit_provider_id in (select id from providers where provider_key = 'google-antigravity'); + delete from providers where provider_key = 'google-antigravity'; + update credentials set ciphertext = x'' where provider_key = 'google-antigravity'; + delete from integration_keys where name = 'google_antigravity_client_secret'; + update app_state + set onboarding_draft_json = json_set(onboarding_draft_json, '$.step', 'provider', '$.providerKey', 'openai', '$.providerModel', 'gpt-6.1-sol', '$.providerBaseUrl', '') + where json_extract(onboarding_draft_json, '$.providerKey') = 'google-antigravity'; +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 6e27647f..20917b68 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -109,6 +109,7 @@ import { searchPendingSchema } from "./migrations/107-search-pending"; import { dropAutomationRulesSchema } from "./migrations/108-drop-automation-rules"; import { dropMeetingsSchema } from "./migrations/109-drop-meetings"; import { dropSprintsSchema } from "./migrations/110-drop-sprints"; +import { dropGoogleAntigravitySchema } from "./migrations/111-drop-google-antigravity"; interface Migration { version: number; @@ -227,6 +228,7 @@ const MIGRATIONS: Migration[] = [ { version: 108, name: "drop_automation_rules", sql: dropAutomationRulesSchema }, { version: 109, name: "drop_meetings", sql: dropMeetingsSchema }, { version: 110, name: "drop_sprints", sql: dropSprintsSchema }, + { version: 111, name: "drop_google_antigravity", sql: dropGoogleAntigravitySchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/index.ts b/src/main/index.ts index 9cfa8b84..a1c0f334 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,6 @@ import { app } from "electron"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { DatabaseSync } from "node:sqlite"; import { DatabaseConnection } from "./database/connection"; import { appPageUrl, createMainWindow } from "./app/create-window"; import { registerIpcHandlers } from "./app/ipc-handlers"; @@ -13,6 +12,7 @@ import { recoverInterruptedWork } from "./app/startup-recovery"; import { startTaskQueueWorker } from "./app/task-queue-worker"; import { confirmDiscardOnClose } from "./app/unsaved-guard"; import { stopAllRunsAndWait } from "./services/run-control"; +import { appLanguage } from "./services/work-language"; const QUIT_GRACE_MS = 10_000; @@ -28,7 +28,7 @@ if (!app.requestSingleInstanceLock()) { // strings, date formatting) is only settable pre-ready, and it follows the // stored language preference instead of the OS locale. const connection = new DatabaseConnection(join(profileDir, "agentcompany.db")); - const language = storedLanguage(connection.db); + const language = appLanguage(connection.db); app.commandLine.appendSwitch("lang", language === "zh" ? "zh-CN" : "en-US"); app.whenReady().then(() => { @@ -98,11 +98,6 @@ if (!app.requestSingleInstanceLock()) { }); } -function storedLanguage(db: DatabaseSync): string { - const row = db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined; - return row?.language ?? "en"; -} - /** * The bundled skill catalogue: the repo folder during development, the * extraResources copy in a packaged build, or an explicit override for diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index a082c082..350a9cb7 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -18,6 +18,7 @@ import { sendMessageTool } from "./agent-send-message"; import { withSavepoint } from "../database/connection"; import { optionalEnum, optionalString, optionalStringArray, optionalText, requireString } from "./agent-tool-args"; import { CEO_FIRST_HIRES } from "./org-building"; +import { appLanguage } from "./work-language"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -435,8 +436,11 @@ function postAssignmentMessage( { type: "agent", id: ctx.agent.id }, { type: "agent", id: input.assignee.id }, ); + const mention = `@${canonicalMentionHandle(input.assignee.name)}`; const body = [ - `@${canonicalMentionHandle(input.assignee.name)} take task_id=${input.taskId}: ${input.title}.`, + appLanguage(deps.db) === "zh" + ? `${mention} 请接手 task_id=${input.taskId}:${input.title}。` + : `${mention} take task_id=${input.taskId}: ${input.title}.`, input.description, ].join(" "); const messageId = deps.chat.sendMessage({ diff --git a/src/main/services/chat-wake-context.ts b/src/main/services/chat-wake-context.ts index 9da33c15..ad0179bc 100644 --- a/src/main/services/chat-wake-context.ts +++ b/src/main/services/chat-wake-context.ts @@ -30,16 +30,18 @@ export function loadChatWakeContext(db: DatabaseSync, orgId: string, agentId: st const thread = db.prepare("select space_id from chat_threads where id = ? and org_id = ?") .get(scope.sourceThreadId, orgId) as { space_id: string } | undefined; if (!thread) throw new Error(`Chat wake thread ${scope.sourceThreadId} no longer exists.`); - const lastReplyAt = (db.prepare(` - select coalesce(max(created_at), '') as at from chat_messages + const lastReply = db.prepare(` + select created_at as at, rowid as seq from chat_messages where thread_id = ? and org_id = ? and sender_type = 'agent' and sender_id = ? and deleted_at is null - `).get(scope.sourceThreadId, orgId, agentId) as { at: string }).at; - const isFresh = `not (m.sender_type = 'agent' and m.sender_id = ?) and (m.created_at > ? or coalesce(m.edited_at, '') > ?)`; + order by created_at desc, rowid desc limit 1 + `).get(scope.sourceThreadId, orgId, agentId) as { at: string; seq: number } | undefined; + const at = lastReply?.at ?? ""; + const isFresh = `not (m.sender_type = 'agent' and m.sender_id = ?) and ((m.created_at, m.rowid) > (?, ?) or coalesce(m.edited_at, '') >= ?)`; const inThread = "m.thread_id = ? and m.org_id = ? and m.deleted_at is null"; - const args = [scope.sourceThreadId, orgId, agentId, lastReplyAt, lastReplyAt]; - const fresh = db.prepare(`${MESSAGE_SELECT} where ${inThread} and ${isFresh} order by m.created_at asc`) + const args = [scope.sourceThreadId, orgId, agentId, at, lastReply?.seq ?? 0, at]; + const fresh = db.prepare(`${MESSAGE_SELECT} where ${inThread} and ${isFresh} order by m.created_at asc, m.rowid asc`) .all(...args) as unknown as ThreadMessageRow[]; - const history = (db.prepare(`${MESSAGE_SELECT} where ${inThread} and not (${isFresh}) order by m.created_at desc limit ?`) + const history = (db.prepare(`${MESSAGE_SELECT} where ${inThread} and not (${isFresh}) order by m.created_at desc, m.rowid desc limit ?`) .all(...args, HISTORY_LIMIT) as unknown as ThreadMessageRow[]).reverse(); const historyTotal = (db.prepare(`select count(*) as n from chat_messages m where ${inThread} and not (${isFresh})`) .get(...args) as { n: number }).n; @@ -98,24 +100,26 @@ function utcMinute(iso: string): string { return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`; } -/** Polls the threads this run is part of for messages from others. */ +/** Polls the threads this run is part of for messages from others that it has not seen; ids, not timestamps, mark what was seen. */ export function createRunMessagePoller( db: DatabaseSync, input: { orgId: string; agentId: string; sourceThreadId: string | null; runStartedAt: string; onInjected: (entries: ReadEntry[]) => void }, ): () => string | null { - let watermark = input.runStartedAt; + const since = db.prepare("select id from chat_messages where org_id = ? and created_at >= ?"); + const seen = new Set((since.all(input.orgId, input.runStartedAt) as Array<{ id: string }>).map((row) => row.id)); return () => { - const rows = db.prepare(`${MESSAGE_SELECT} - where m.org_id = ? and m.created_at > ? and m.deleted_at is null + const rows = (db.prepare(`${MESSAGE_SELECT} + where m.org_id = ? and m.created_at >= ? and m.deleted_at is null and not (m.sender_type = 'agent' and m.sender_id = ?) and (m.thread_id = ? or m.thread_id in ( select own.thread_id from chat_messages own where own.org_id = ? and own.sender_type = 'agent' and own.sender_id = ? and own.created_at >= ? )) - order by m.created_at asc - `).all(input.orgId, watermark, input.agentId, input.sourceThreadId, input.orgId, input.agentId, input.runStartedAt) as unknown as ThreadMessageRow[]; + order by m.created_at asc, m.rowid asc + `).all(input.orgId, input.runStartedAt, input.agentId, input.sourceThreadId, input.orgId, input.agentId, input.runStartedAt) as unknown as ThreadMessageRow[]) + .filter((row) => !seen.has(row.id)); + for (const row of rows) seen.add(row.id); if (rows.length === 0) return null; - watermark = rows[rows.length - 1].created_at; input.onInjected(rows.map(readEntry)); console.info(`[run-inbox] agent ${input.agentId}: ${rows.length} new message(s) injected mid-run`); return ["Messages that arrived while you were working (they may change what you should do):", ...rows.map((m) => formatMessage(db, m))].join("\n"); diff --git a/src/main/services/oauth/google-antigravity-flow.ts b/src/main/services/oauth/google-antigravity-flow.ts deleted file mode 100644 index c5302cc1..00000000 --- a/src/main/services/oauth/google-antigravity-flow.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Google Antigravity (Gemini 3 / Claude / GPT-OSS via Google Cloud) — - * authorization-code + PKCE flow with project discovery. - * - * Ported from the reference implementation in @mariozechner/pi-ai. - */ -import { startOAuthCallbackServer } from "./oauth-callback-server"; -import { generatePkce } from "./pkce"; -import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types"; -import { httpFailure } from "../provider-http"; -import { oauthFetch } from "./oauth-fetch"; - -const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -const CALLBACK_PORT = 51121; -const CALLBACK_PATH = "/oauth-callback"; -const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`; -const SCOPES = [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/cclog", - "https://www.googleapis.com/auth/experimentsandconfigs", -]; -const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_URL = "https://oauth2.googleapis.com/token"; -const CODE_ASSIST_ENDPOINTS = ["https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com"]; - -export function requireAntigravityClientSecret(secret: string | null | undefined): string { - if (!secret) throw new Error("Google Antigravity sign-in needs its OAuth client secret. Add it in Settings > Integrations."); - return secret; -} - -function buildAuthorizeUrl(challenge: string, state: string): string { - const params = new URLSearchParams({ - client_id: CLIENT_ID, - response_type: "code", - redirect_uri: REDIRECT_URI, - scope: SCOPES.join(" "), - code_challenge: challenge, - code_challenge_method: "S256", - state, - access_type: "offline", - prompt: "consent", - }); - return `${AUTH_URL}?${params.toString()}`; -} - -async function exchangeCode(code: string, verifier: string, clientSecret: string, cancel?: AbortSignal): Promise<{ access: string; refresh: string; expires: number }> { - const { response, text } = await oauthFetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: REDIRECT_URI, - code_verifier: verifier, - }), - }, cancel); - if (!response.ok) throw new Error(`Token exchange failed: ${response.status} ${text}`); - const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; - if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") { - throw new Error("Token response was missing required fields."); - } - return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 }; -} - -/** Extract a Code Assist project id from a loadCodeAssist response (unit-tested). */ -export function readProjectId(payload: Record): string | null { - const project = payload.cloudaicompanionProject; - if (typeof project === "string" && project) return project; - if (project && typeof project === "object" && "id" in project) { - const id = (project as { id?: unknown }).id; - if (typeof id === "string" && id) return id; - } - return null; -} - -async function discoverProject(accessToken: string, cancel?: AbortSignal): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": "google-api-nodejs-client/9.15.1", - }; - for (const endpoint of CODE_ASSIST_ENDPOINTS) { - try { - const { response, text } = await oauthFetch(`${endpoint}/v1internal:loadCodeAssist`, { - method: "POST", - headers, - body: JSON.stringify({ metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } }), - }, cancel); - if (response.ok) { - const projectId = readProjectId(JSON.parse(text) as Record); - if (projectId) return projectId; - } - } catch (error) { - // A cancelled sign-in ends here; any other failure moves on to the next endpoint. - if (cancel?.aborted) throw error; - } - } - throw new Error("Antigravity project discovery failed."); -} - -/** Revokes the sign-in at Google; revoking the refresh token also ends its access tokens. */ -export async function revokeAntigravity(refreshToken: string): Promise { - const { response, text } = await oauthFetch("https://oauth2.googleapis.com/revoke", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ token: refreshToken }), - }); - if (!response.ok) throw httpFailure(`Google revocation failed: ${response.status} ${text}`, response, text); -} - -/** Refresh an expired Antigravity access token, retaining the project id. */ -export async function refreshAntigravity(refreshToken: string, projectId: string, clientSecret: string): Promise { - const { response, text } = await oauthFetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }); - if (!response.ok) throw httpFailure(`Antigravity token refresh failed: ${response.status} ${text}`, response, text); - const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; - if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") { - throw new Error("Antigravity token refresh response was missing required fields."); - } - const refresh = typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : refreshToken; - return { access: json.access_token, refresh, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000, projectId }; -} - -export const antigravityFlow: OAuthFlow = { - providerKey: "google-antigravity", - async login(callbacks: OAuthLoginCallbacks): Promise { - const clientSecret = requireAntigravityClientSecret(callbacks.clientSecret); - const { verifier, challenge } = generatePkce(); - const server = await startOAuthCallbackServer({ port: CALLBACK_PORT, path: CALLBACK_PATH, signal: callbacks.signal }); - try { - callbacks.onAuth({ url: buildAuthorizeUrl(challenge, verifier), userCode: null }); - const result = await server.waitForCode(); - if (result.state !== verifier) throw new Error("OAuth state mismatch."); - const tokens = await exchangeCode(result.code, verifier, clientSecret, callbacks.signal); - callbacks.onProgress?.("Resolving project"); - const projectId = await discoverProject(tokens.access, callbacks.signal); - return { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, projectId }; - } finally { - server.close(); - } - }, -}; diff --git a/src/main/services/oauth/oauth-flows.ts b/src/main/services/oauth/oauth-flows.ts index 463fce6f..9b87ed4b 100644 --- a/src/main/services/oauth/oauth-flows.ts +++ b/src/main/services/oauth/oauth-flows.ts @@ -1,6 +1,5 @@ import { githubCopilotFlow } from "./github-copilot-flow"; import { openaiCodexFlow } from "./openai-codex-flow"; -import { antigravityFlow } from "./google-antigravity-flow"; import { anthropicClaudeFlow } from "./anthropic-claude-flow"; import { kimiCodeFlow } from "./kimi-code-flow"; import type { OAuthFlow } from "./oauth-types"; @@ -9,7 +8,6 @@ import type { OAuthFlow } from "./oauth-types"; const OAUTH_FLOWS: Record = { "github-copilot": githubCopilotFlow, "gpt-account": openaiCodexFlow, - "google-antigravity": antigravityFlow, "claude-pro": anthropicClaudeFlow, "kimi-code": kimiCodeFlow, }; diff --git a/src/main/services/oauth/oauth-types.ts b/src/main/services/oauth/oauth-types.ts index afc9ad8a..92e7f86f 100644 --- a/src/main/services/oauth/oauth-types.ts +++ b/src/main/services/oauth/oauth-types.ts @@ -21,8 +21,6 @@ export interface OAuthLoginCallbacks { signal?: AbortSignal; /** GitHub Enterprise domain for Copilot; null/empty means github.com. */ enterpriseDomain?: string | null; - /** Client secret for flows that need one. */ - clientSecret?: string | null; } export interface OAuthFlow { diff --git a/src/main/services/oauth/stored-oauth.ts b/src/main/services/oauth/stored-oauth.ts index 600e377f..fb50882d 100644 --- a/src/main/services/oauth/stored-oauth.ts +++ b/src/main/services/oauth/stored-oauth.ts @@ -6,14 +6,12 @@ export interface StoredOAuth { refresh: string; expires: number; accountId?: string; - projectId?: string; enterpriseUrl?: string | null; } /** Extras a provider's turns cannot run without, with the words used when one is missing. */ -const REQUIRED_EXTRAS: Record = { +const REQUIRED_EXTRAS: Record = { "gpt-account": { field: "accountId", label: "ChatGPT account id" }, - "google-antigravity": { field: "projectId", label: "Google Cloud project id" }, }; /** The one check of a stored sign-in, used both when it is saved and when a run uses it. */ diff --git a/src/main/services/provider-adapter-contract.ts b/src/main/services/provider-adapter-contract.ts index f9612653..882b85a8 100644 --- a/src/main/services/provider-adapter-contract.ts +++ b/src/main/services/provider-adapter-contract.ts @@ -3,7 +3,7 @@ import type { ProviderModelEntry } from "../../shared/provider-models"; import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain"; import type { ProviderAuthProfile } from "./provider-auth-profiles"; import type { ProviderBackgroundCreateResult, ProviderBackgroundRetrieveResult } from "./provider-background"; -import type { ProviderAuthorizeHandler, ProviderRefreshHandler, ProviderRevokeHandler } from "./provider-auth-lifecycle"; +import type { ProviderAuthorizeHandler, ProviderRefreshHandler } from "./provider-auth-lifecycle"; import type { ProviderCancelResult, ProviderStreamResult } from "./provider-streaming"; export interface ProviderExecutionInput { @@ -50,7 +50,6 @@ export interface ProviderAdapter { authType: ProviderAuthType; authorize: ProviderAuthorizeHandler; refresh: ProviderRefreshHandler; - revoke: ProviderRevokeHandler; parseAuthProfile(secret: string): ProviderAuthProfile; listModels(ctx: ProviderContext): Promise; getRateLimitState(ctx: ProviderContext): Promise; diff --git a/src/main/services/provider-adapters.ts b/src/main/services/provider-adapters.ts index b821c602..bdfbb3f9 100644 --- a/src/main/services/provider-adapters.ts +++ b/src/main/services/provider-adapters.ts @@ -9,16 +9,10 @@ import { parseOpenAiCompatProfile, parseVertexProfile, requireAccessTokenProfile, - requireAccountTokenProfile, - requireAnthropicCompatProfile, requireApiKeyProfile, - requireAzureProfile, - requireBedrockProfile, - requireOpenAiCompatProfile, - requireVertexProfile, } from "./provider-auth-profiles"; import { backgroundUnsupported, retrieveUnsupported } from "./provider-background"; -import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./provider-auth-lifecycle"; +import { authorizeWithParser, refreshUnsupported } from "./provider-auth-lifecycle"; import { getJson, postEventStream, postJson, WHOLE_RESPONSE_WAIT, type JsonRecord } from "./provider-http"; import { OUTPUT_RESERVE_TOKENS } from "./context-budget"; import { chatMaxTokensField } from "./provider-tool-turns/chat-format"; @@ -26,8 +20,6 @@ import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow"; import { FINISHED_STOP_REASONS, incompleteTurn, requireFinished } from "./provider-tool-turn"; import { incompleteReason } from "./provider-tool-turns/responses-format"; -import { revokeAntigravity } from "./oauth/google-antigravity-flow"; -import { parseStoredOAuth } from "./oauth/stored-oauth"; const ANTHROPIC_VERSION = "2023-06-01"; const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14"; @@ -55,7 +47,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.API_KEY, authorize: authorizeWithParser(parseApiKey), refresh: refreshUnsupported("OpenAI API key"), - revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "OpenAI"); }), parseAuthProfile: parseApiKey, listModels: (ctx) => fetchOpenAiModels("https://api.openai.com/v1/models", { authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, "OpenAI").apiKey}`, @@ -138,7 +129,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.API_KEY, authorize: authorizeWithParser(parseApiKey), refresh: refreshUnsupported("Anthropic API key"), - revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, "Anthropic"); }), parseAuthProfile: parseApiKey, listModels: (ctx) => fetchAnthropicModels("https://api.anthropic.com/v1/models", { "x-api-key": requireApiKeyProfile(ctx.authProfile, "Anthropic").apiKey, @@ -224,10 +214,6 @@ export const providerAdapters: ProviderAdapterRegistry = { healthUrl: "https://api.moonshot.cn/v1/models", }), "github-copilot": browserTokenAdapter("github-copilot", PROVIDER_AUTH_TYPE.OAUTH_PKCE), - "google-antigravity": { - ...browserTokenAdapter("google-antigravity", PROVIDER_AUTH_TYPE.OAUTH_PKCE), - revoke: async (input) => revokeAntigravity(parseStoredOAuth("google-antigravity", input.secret).refresh), - }, "gpt-account": browserTokenAdapter("gpt-account", PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION), "claude-pro": { ...browserTokenAdapter("claude-pro", PROVIDER_AUTH_TYPE.OAUTH_PKCE), @@ -248,7 +234,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.CLOUD, authorize: authorizeWithParser(parseAzureProfile), refresh: refreshUnsupported("Azure OpenAI cloud credential"), - revoke: revokeLocalProfile((profile) => { requireAzureProfile(profile); }), parseAuthProfile: parseAzureProfile, listModels: () => listStaticModels("azure-openai"), getRateLimitState: providerRateLimitUnknown, @@ -262,7 +247,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.CLOUD, authorize: authorizeWithParser(parseBedrockProfile), refresh: refreshUnsupported("AWS Bedrock cloud credential"), - revoke: revokeLocalProfile((profile) => { requireBedrockProfile(profile); }), parseAuthProfile: parseBedrockProfile, listModels: () => listStaticModels("aws-bedrock"), getRateLimitState: providerRateLimitUnknown, @@ -276,7 +260,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.CLOUD, authorize: authorizeWithParser(parseVertexProfile), refresh: refreshUnsupported("Vertex AI cloud credential"), - revoke: revokeLocalProfile((profile) => { requireVertexProfile(profile); }), parseAuthProfile: parseVertexProfile, listModels: () => listStaticModels("vertex-ai"), getRateLimitState: providerRateLimitUnknown, @@ -290,7 +273,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.CLOUD, authorize: authorizeWithParser(parseOpenAiCompatProfile), refresh: refreshUnsupported("Custom OpenAI-compatible endpoint"), - revoke: revokeLocalProfile((profile) => { requireOpenAiCompatProfile(profile); }), parseAuthProfile: parseOpenAiCompatProfile, listModels: () => listStaticModels("custom-openai"), getRateLimitState: providerRateLimitUnknown, @@ -304,7 +286,6 @@ export const providerAdapters: ProviderAdapterRegistry = { authType: PROVIDER_AUTH_TYPE.CLOUD, authorize: authorizeWithParser(parseAnthropicCompatProfile), refresh: refreshUnsupported("Custom Anthropic-compatible endpoint"), - revoke: revokeLocalProfile((profile) => { requireAnthropicCompatProfile(profile); }), parseAuthProfile: parseAnthropicCompatProfile, listModels: () => listStaticModels("custom-anthropic"), getRateLimitState: providerRateLimitUnknown, @@ -363,7 +344,6 @@ function openAiCompatibleAdapter({ authType: PROVIDER_AUTH_TYPE.API_KEY, authorize: authorizeWithParser(parseApiKey), refresh: refreshUnsupported(`${label} API key`), - revoke: revokeLocalProfile((profile) => { requireApiKeyProfile(profile, label); }), parseAuthProfile: parseApiKey, listModels: (ctx) => fetchOpenAiModels(healthUrl, { authorization: `Bearer ${requireApiKeyProfile(ctx.authProfile, label).apiKey}`, @@ -413,13 +393,11 @@ function responseMessages(instructions: string | null, prompt: string): Array<{ function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter { const parseAuthProfile = oauthProfileParser(providerKey, authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? "account_token" : "access_token"); - const assertProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? requireAccountTokenProfile : requireAccessTokenProfile; return { providerKey, authType, authorize: authorizeWithParser(parseAuthProfile), refresh: refreshUnsupported(`${providerKey} token`), - revoke: revokeLocalProfile((profile) => { assertProfile(profile); }), parseAuthProfile, listModels: () => listStaticModels(providerKey), getRateLimitState: providerRateLimitUnknown, diff --git a/src/main/services/provider-auth-lifecycle.ts b/src/main/services/provider-auth-lifecycle.ts index 159d3276..5e4802e5 100644 --- a/src/main/services/provider-auth-lifecycle.ts +++ b/src/main/services/provider-auth-lifecycle.ts @@ -18,15 +18,8 @@ export interface ProviderRefreshResult { refreshedAt: string; } -export interface ProviderRevokeInput { - authProfile: ProviderAuthProfile; - /** The stored credential, for providers that revoke with the refresh token. */ - secret: string; -} - export type ProviderAuthorizeHandler = (input: ProviderAuthorizeInput) => Promise; export type ProviderRefreshHandler = (input: ProviderRefreshInput) => Promise; -export type ProviderRevokeHandler = (input: ProviderRevokeInput) => Promise; export function authorizeWithParser(parseAuthProfile: (secret: string) => ProviderAuthProfile): ProviderAuthorizeHandler { return async (input) => ({ authProfile: parseAuthProfile(input.secret) }); @@ -37,9 +30,3 @@ export function refreshUnsupported(label: string): ProviderRefreshHandler { throw new Error(`${label} does not support token refresh.`); }; } - -export function revokeLocalProfile(assertProfile: (profile: ProviderAuthProfile) => void): ProviderRevokeHandler { - return async (input) => { - assertProfile(input.authProfile); - }; -} diff --git a/src/main/services/provider-auth-profiles.ts b/src/main/services/provider-auth-profiles.ts index 6fa92d99..7b3a619a 100644 --- a/src/main/services/provider-auth-profiles.ts +++ b/src/main/services/provider-auth-profiles.ts @@ -82,11 +82,6 @@ export function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract return profile; } -export function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract { - if (profile.type !== "account_token") throw new Error("Account token profile is required."); - return profile; -} - export function requireAzureProfile(profile: ProviderAuthProfile): Extract { if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required."); return profile; diff --git a/src/main/services/provider-oauth-service.ts b/src/main/services/provider-oauth-service.ts index 2f712bde..f5642833 100644 --- a/src/main/services/provider-oauth-service.ts +++ b/src/main/services/provider-oauth-service.ts @@ -1,5 +1,4 @@ import { getOAuthFlow } from "./oauth/oauth-flows"; -import type { IntegrationKeyStore } from "./integration-key-store"; export interface ProviderOAuthHooks { onAuth: (info: { url: string; userCode: string | null }) => void; @@ -15,8 +14,6 @@ export interface ProviderOAuthHooks { export class ProviderOAuthService { private readonly controllers = new Map(); - constructor(private readonly keys: IntegrationKeyStore) {} - async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise { const flow = getOAuthFlow(input.providerKey); if (this.controllers.has(input.providerKey)) { @@ -30,7 +27,6 @@ export class ProviderOAuthService { onProgress: hooks.onProgress, signal: controller.signal, enterpriseDomain: input.enterpriseDomain ?? null, - clientSecret: input.providerKey === "google-antigravity" ? this.keys.read("google_antigravity_client_secret") : null, }); return JSON.stringify(credentials); } finally { diff --git a/src/main/services/provider-response-runner.ts b/src/main/services/provider-response-runner.ts index 0de70721..d3011281 100644 --- a/src/main/services/provider-response-runner.ts +++ b/src/main/services/provider-response-runner.ts @@ -1,4 +1,3 @@ -import type { IntegrationKeyStore } from "./integration-key-store"; import type { DatabaseSync } from "node:sqlite"; import type { CancelProviderResponseInput, CreateProviderResponseInput, StreamProviderResponseInput } from "../../shared/commands"; import { FEATURE_FLAG_KEY, PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain"; @@ -41,7 +40,6 @@ export interface ProviderResponseRunnerDeps { adapters: ProviderAdapterRegistry; audit: AuditService; authBroker: AuthBrokerService; - integrationKeys: IntegrationKeyStore; operations: OperationsService; rateLimitRegistry: ProviderRateLimitRegistry; responseObserver: ProviderResponseObserver; @@ -122,7 +120,7 @@ export class ProviderResponseRunner { const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId); const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null); if (!supportsToolTurn(provider.provider_key)) { - throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, Google Antigravity, or Claude Pro provider."); + throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, or Claude Pro provider."); } // A call made for a run works for its agent: the agent's model, its budget, its attribution. const taskRunId = currentRunId(); @@ -198,7 +196,7 @@ export class ProviderResponseRunner { }); const startedAt = Date.now(); try { - const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId, provider: owner }); + const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId, provider: owner }); const result = await turn({ credential, model, instructions: request.instructions, messages: request.messages, tools: request.tools }); recordObservedRateLimit(this.deps.rateLimitRegistry, orgId, owner, model, result.rateLimitState ?? null); // Tool calls are output the model wrote, as much as its text. diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index ab1d9963..76faae82 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -68,7 +68,6 @@ export class ProviderService { adapters, audit: this.audit, authBroker: this.authBroker, - integrationKeys: this.integrationKeys, operations: this.operations, rateLimitRegistry: this.rateLimitRegistry, responseObserver: this.responseObserver, @@ -136,40 +135,27 @@ export class ProviderService { return providerEndpoint(this.db, this.authBroker, orgId, providerId); } - /** Revokes at the provider where it can, then always disconnects and erases the credential here; a remote failure is reported, not hidden. */ - async revokeCredential(input: RevokeProviderCredentialInput): Promise { + /** Disconnects the provider and erases its credential on this computer. */ + revokeCredential(input: RevokeProviderCredentialInput): void { const provider = this.db.prepare(` - select id, provider_key, auth_type, label, credential_id + select provider_key, label, credential_id from providers where id = ? and org_id = ? - `).get(input.providerId, input.orgId) as { id: string; provider_key: string; auth_type: ProviderAuthType; label: string; credential_id: string | null } | undefined; + `).get(input.providerId, input.orgId) as { provider_key: string; label: string; credential_id: string | null } | undefined; if (!provider) throw new Error("Provider was not found."); if (!provider.credential_id) throw new Error(`${provider.label} is not connected.`); const credentialId = provider.credential_id; - const entry = requireCatalogEntry(provider.provider_key); - let remoteError: string | null = null; - if (entry.revokesRemotely) { - try { - const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); - const secret = this.authBroker.loadProviderSecret({ orgId: input.orgId, providerId: provider.id, providerKey: provider.provider_key, credentialId }); - await adapter.revoke({ authProfile: adapter.parseAuthProfile(secret), secret }); - } catch (error) { - remoteError = error instanceof Error ? error.message : String(error); - } + if (!this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId })) { + throw new Error(`${provider.label} has no active sign-in to disconnect.`); } - // The credential read at the start is the one disconnected; a connection made while Google answered stays. - const disconnected = this.authBroker.revokeProviderProfile({ orgId: input.orgId, providerId: input.providerId, providerKey: provider.provider_key, credentialId }); - if (disconnected) { - this.db.prepare(` - update providers - set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null - where id = ? and org_id = ? and credential_id = ? - `).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId); - // Sessions mid-turn on this provider stop now instead of finishing on a revoked account. - const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>; - for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`); - } - const remote = !entry.revokesRemotely ? "not_supported" : remoteError ? "failed" : "revoked"; + this.db.prepare(` + update providers + set credential_id = null, enabled = 0, auth_state = ?, health_state = ?, last_error = null, last_checked_at = null + where id = ? and org_id = ? and credential_id = ? + `).run(AUTH_STATE_REVOKED, HEALTH_STATE_UNCHECKED, input.providerId, input.orgId, credentialId); + // Sessions mid-turn on this provider stop now instead of finishing on a revoked account. + const running = this.db.prepare("select id from task_runs where provider_id = ? and state = 'running'").all(input.providerId) as Array<{ id: string }>; + for (const run of running) stopRun(run.id, `${provider.label} was disconnected.`); this.audit.record({ orgId: input.orgId, entityType: "provider", @@ -177,14 +163,9 @@ export class ProviderService { actorType: "system", actorId: null, action: "credential_revoked", - payload: { providerKey: provider.provider_key, remote, remoteError, replacedMeanwhile: !disconnected }, + payload: { providerKey: provider.provider_key }, }); - console.info(`[providers] ${provider.provider_key} disconnected; remote revocation ${remote}`); - const problems = [ - ...(disconnected ? [] : [`${provider.label} was reconnected while it was being disconnected; the old sign-in is erased and the new connection stays.`]), - ...(remoteError ? [`${disconnected ? "Disconnected on this computer, but " : "The old sign-in was not confirmed revoked: "}${provider.label} did not confirm revoking the sign-in (${remoteError}); revoke it in your account.`] : []), - ]; - if (problems.length > 0) throw new Error(problems.join(" ")); + console.info(`[providers] ${provider.provider_key} disconnected`); } /** @@ -204,7 +185,7 @@ export class ProviderService { const provider = loadConfiguredProvider(this.db, input.orgId, input.providerId); const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); // Renew an expired sign-in the way a run does, so the list and the runs agree on whether the account works. - await resolveTurnCredential({ authBroker: this.authBroker, integrationKeys: this.integrationKeys, orgId: input.orgId, provider }); + await resolveTurnCredential({ authBroker: this.authBroker, orgId: input.orgId, provider }); const credential = loadCredential(this.authBroker, input.orgId, provider); const authProfile = adapter.parseAuthProfile(credential.secret); return adapter.listModels({ authProfile, model: provider.model }); diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index 77aa3698..c4878f50 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -3,7 +3,7 @@ * pass the key straight through; OAuth/subscription providers parse the stored * credential JSON, refresh the access token when it has expired (persisting the * rotated token), and hand the turn a usable access token plus the extras - * (accountId / projectId / enterpriseUrl) its backend needs. + * (accountId / enterpriseUrl) its backend needs. */ import { PROVIDER_AUTH_TYPE } from "../../../shared/domain"; import type { AuthBrokerService } from "../auth-broker-service"; @@ -26,21 +26,17 @@ import type { TurnCredential } from "../provider-tool-turn"; import type { OAuthCredentials } from "../oauth/oauth-types"; import { refreshCodex } from "../oauth/openai-codex-flow"; import { refreshClaudePro } from "../oauth/anthropic-claude-flow"; -import { refreshAntigravity, requireAntigravityClientSecret } from "../oauth/google-antigravity-flow"; -import type { IntegrationKeyStore } from "../integration-key-store"; import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; import { refreshKimiCode } from "../oauth/kimi-code-flow"; import { parseStoredOAuth, type StoredOAuth } from "../oauth/stored-oauth"; import { untilStopped } from "../run-control"; -function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise { +function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise { switch (providerKey) { case "gpt-account": return refreshCodex(creds.refresh); case "claude-pro": return refreshClaudePro(creds.refresh); - case "google-antigravity": - return refreshAntigravity(creds.refresh, requireProjectId(creds), requireAntigravityClientSecret(keys.read("google_antigravity_client_secret"))); case "github-copilot": return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined); case "kimi-code": @@ -82,14 +78,12 @@ function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential return { accessToken: creds.access, accountId: typeof creds.accountId === "string" ? creds.accountId : undefined, - projectId: typeof creds.projectId === "string" ? creds.projectId : undefined, enterpriseUrl: typeof enterpriseUrl === "string" ? enterpriseUrl : enterpriseUrl === null ? null : undefined, }; } export async function resolveTurnCredential(deps: { authBroker: AuthBrokerService; - integrationKeys: IntegrationKeyStore; orgId: string; provider: ProviderRow; }): Promise { @@ -109,13 +103,13 @@ export async function resolveTurnCredential(deps: { /** Refreshes in flight, by stored credential: a rotating provider honours a refresh token once, so callers at once share one. */ const refreshing = new Map>(); -function refreshOnce(deps: { authBroker: AuthBrokerService; integrationKeys: IntegrationKeyStore; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise { +function refreshOnce(deps: { authBroker: AuthBrokerService; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise { const credentialId = deps.provider.credential_id; if (!credentialId) throw new Error("Provider credential id is missing; reconnect the provider."); const running = refreshing.get(credentialId); if (running) return running; console.info(`[credentials] refreshing the ${deps.provider.provider_key} sign-in`); - const refresh = refreshOAuth(deps.provider.provider_key, creds, deps.integrationKeys) + const refresh = refreshOAuth(deps.provider.provider_key, creds) .then((refreshed) => { deps.authBroker.updateProviderSecret({ orgId: deps.orgId, providerKey: deps.provider.provider_key, credentialId, secret: JSON.stringify(refreshed) }); return refreshed; @@ -124,8 +118,3 @@ function refreshOnce(deps: { authBroker: AuthBrokerService; integrationKeys: Int refreshing.set(credentialId, refresh); return refresh; } - -function requireProjectId(creds: StoredOAuth): string { - if (!creds.projectId) throw new Error("The Google Antigravity sign-in is missing its Google Cloud project id; sign in again."); - return creds.projectId; -} diff --git a/src/main/services/provider-tool-turns/gemini-format.ts b/src/main/services/provider-tool-turns/gemini-format.ts index 6ab45ee8..c9ca19c9 100644 --- a/src/main/services/provider-tool-turns/gemini-format.ts +++ b/src/main/services/provider-tool-turns/gemini-format.ts @@ -1,37 +1,21 @@ -/** - * Google Cloud Code Assist (Gemini) encoding for the Antigravity backend. - * Ported from Pi's google-shared `convertMessages` / `convertTools` and the - * Antigravity request wrapper in google-gemini-cli. - */ +/** Gemini generateContent encoding for Vertex AI. */ import { OUTPUT_RESERVE_TOKENS } from "../context-budget"; -import { isRecord, ProviderHttpError, readProviderErrorCode, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; -import { FINISHED_STOP_REASONS, replayIn, requireFinished, toolCallFromObject, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; +import { isRecord, type JsonRecord } from "../provider-http"; +import { FINISHED_STOP_REASONS, replayIn, requireFinished, toolCallFromObject, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; -const ANTIGRAVITY_SYSTEM_INSTRUCTION = - "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." + - "You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." + - "**Absolute paths only**" + - "**Proactiveness**"; - -function usesToolCallId(model: string): boolean { - return model.startsWith("claude-") || model.startsWith("gpt-oss-"); -} - -function geminiToolDefs(model: string, tools: ToolDefinition[]): unknown[] { - const useParameters = model.startsWith("claude-"); +function geminiToolDefs(tools: ToolDefinition[]): unknown[] { return [ { functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, - ...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }), + parametersJsonSchema: tool.parameters, })), }, ]; } -function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonRecord[] { - const withId = usesToolCallId(model); +function toGeminiContents(messages: AgentTurnMessage[]): JsonRecord[] { const contents: JsonRecord[] = []; for (const message of messages) { if (message.role === "user") { @@ -45,12 +29,12 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco const parts: unknown[] = []; if (message.text) parts.push({ text: message.text }); for (const call of message.toolCalls) { - parts.push({ functionCall: { name: call.name, args: call.arguments, ...(withId ? { id: call.id } : {}) } }); + parts.push({ functionCall: { name: call.name, args: call.arguments } }); } if (parts.length > 0) contents.push({ role: "model", parts }); } else { const responses: unknown[] = message.results.map((result) => ({ - functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) }, + functionResponse: { name: result.toolName, response: { output: result.output } }, })); if (message.followUp) responses.push({ text: message.followUp }); const last = contents[contents.length - 1]; @@ -64,45 +48,16 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco return contents; } -/** The full Antigravity request envelope ({project, model, request, ...}). */ -export function antigravityRequestBody(input: { - model: string; - projectId: string; - instructions: string | null; - messages: AgentTurnMessage[]; - tools: ToolDefinition[]; - requestId: string; -}): JsonRecord { - const request: JsonRecord = { - contents: toGeminiContents(input.model, input.messages), - generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS }, - systemInstruction: { - role: "user", - parts: [ - { text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, - { text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` }, - ...(input.instructions ? [{ text: input.instructions }] : []), - ], - }, - }; - if (input.tools.length > 0) { - request.tools = geminiToolDefs(input.model, input.tools); - request.toolConfig = { functionCallingConfig: { mode: "AUTO" } }; - } - return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId }; -} - -/** The Vertex AI generateContent body (Gemini, no Antigravity wrapper). */ +/** The Vertex AI generateContent body. */ export function vertexRequestBody(input: { - model: string; instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[]; }): JsonRecord { - const body: JsonRecord = { contents: toGeminiContents(input.model, input.messages), generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS } }; + const body: JsonRecord = { contents: toGeminiContents(input.messages), generationConfig: { maxOutputTokens: OUTPUT_RESERVE_TOKENS } }; if (input.instructions) body.systemInstruction = { parts: [{ text: input.instructions }] }; if (input.tools.length > 0) { - body.tools = geminiToolDefs(input.model, input.tools); + body.tools = geminiToolDefs(input.tools); body.toolConfig = { functionCallingConfig: { mode: "AUTO" } }; } return body; @@ -135,40 +90,3 @@ export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls const result = partsToResult(parts); return { text: result.text.join("").trim(), toolCalls: result.toolCalls, replay: { format: "gemini", content: parts } }; } - -export async function accumulateGeminiStream(messages: AsyncIterable): Promise { - const raw: string[] = []; - const textParts: string[] = []; - const toolCalls: ToolCall[] = []; - const replayParts: unknown[] = []; - let finishReason: unknown = null; - let counter = 0; - for await (const message of messages) { - raw.push(message.rawData); - if (isRecord(message.data.error)) { - const error = message.data.error; - throw new ProviderHttpError(typeof error.message === "string" ? error.message : "Antigravity stream failed.", null, typeof error.code === "number" ? error.code : null, false, readProviderErrorCode({ error })); - } - const response = isRecord(message.data.response) ? message.data.response : null; - if (!response) continue; - const candidates = Array.isArray(response.candidates) ? response.candidates : []; - if (isRecord(candidates[0]) && typeof candidates[0].finishReason === "string") finishReason = candidates[0].finishReason; - const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null; - const parts = content && Array.isArray(content.parts) ? content.parts : []; - for (const part of parts) { - if (!isRecord(part)) continue; - replayParts.push(part); - if (typeof part.text === "string" && part.thought !== true) { - textParts.push(part.text); - } else if (isRecord(part.functionCall)) { - const fc = part.functionCall; - const name = typeof fc.name === "string" ? fc.name : ""; - if (!name) continue; - const id = typeof fc.id === "string" && fc.id ? fc.id : `${name}_${(counter += 1)}`; - toolCalls.push(toolCallFromObject(id, name, fc.args)); - } - } - } - requireFinished(finishReason, FINISHED_STOP_REASONS.gemini); - return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw), ...(replayParts.length > 0 ? { replay: { format: "gemini", content: replayParts } } : {}) }; -} diff --git a/src/main/services/provider-tool-turns/router.ts b/src/main/services/provider-tool-turns/router.ts index 40400f2f..24c3a88f 100644 --- a/src/main/services/provider-tool-turns/router.ts +++ b/src/main/services/provider-tool-turns/router.ts @@ -2,7 +2,6 @@ import type { ToolTurnFn } from "../provider-tool-turn"; import { makeChatCompletionsTurn, runAnthropicTurn, - runAntigravityTurn, runAzureTurn, runBedrockTurn, runClaudeProTurn, @@ -28,7 +27,6 @@ const TURNS: Record = { "kimi-cn": makeChatCompletionsTurn("kimi-cn", "https://api.moonshot.cn/v1/chat/completions", "Kimi CN"), "gpt-account": runCodexTurn, "github-copilot": runCopilotTurn, - "google-antigravity": runAntigravityTurn, "claude-pro": runClaudeProTurn, "kimi-code": runKimiCodeTurn, "custom-openai": runCustomOpenAiTurn, diff --git a/src/main/services/provider-tool-turns/turns.ts b/src/main/services/provider-tool-turns/turns.ts index 64cafd91..348d6f34 100644 --- a/src/main/services/provider-tool-turns/turns.ts +++ b/src/main/services/provider-tool-turns/turns.ts @@ -9,7 +9,7 @@ import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCred import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format"; import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format"; import { accumulateChatStream, chatMaxTokensField, chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format"; -import { accumulateGeminiStream, antigravityRequestBody, parseGeminiResponse, vertexRequestBody } from "./gemini-format"; +import { parseGeminiResponse, vertexRequestBody } from "./gemini-format"; import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedrock-format"; import { signAwsRequest } from "./aws-sigv4"; import { getGcpAccessToken } from "./gcp-auth"; @@ -17,8 +17,6 @@ import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow"; import { KIMI_CODE_BASE_URL } from "../oauth/kimi-code-flow"; import { OUTPUT_RESERVE_TOKENS } from "../context-budget"; -const ANTIGRAVITY_VERSION = "1.21.9"; -const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com"; const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; function requireApiKey(credential: TurnCredential, label: string): string { @@ -211,27 +209,6 @@ export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Prom return { ...parsed, rawResponseJson: JSON.stringify(response.data), rateLimitState: response.rateLimitState }; } -// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ---- - -export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise { - const access = requireAccessToken(input.credential, "Google Antigravity"); - if (!input.credential.projectId) throw new Error("Antigravity credential is missing its project id."); - const requestId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - const body = antigravityRequestBody({ - model: input.model, - projectId: input.credential.projectId, - instructions: input.instructions, - messages: input.messages, - tools: input.tools, - requestId, - }); - const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, { - Authorization: `Bearer ${access}`, - "User-Agent": `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`, - }, body); - return { ...(await accumulateGeminiStream(response.messages)), rateLimitState: response.rateLimitState }; -} - // ---- Azure OpenAI (Chat Completions at the resource deployment) ---- export async function runAzureTurn(input: ProviderToolTurnInput): Promise { @@ -283,7 +260,7 @@ export async function runVertexTurn(input: ProviderToolTurnInput): Promise void actions.reload().catch(() => undefined)} />; } seedViewState(snapshot.viewState); - const route = routeChoice ?? readViewState("route", isRoute) ?? "home"; + const chosenRoute = routeChoice ?? readViewState("route", isRoute) ?? "home"; + const route = chosenRoute === "connectors" && !connectorsEnabled(snapshot) ? "home" : chosenRoute; // A company exists from Launch on, but its setup is unfinished until the connectors step is done; that is stored, so a restart resumes it. const unfinished = snapshot.organizations.find((organization) => organization.setupCompletedAt === null) ?? null; if (snapshot.organizations.length === 0 || creatingOrg || unfinished) { diff --git a/src/renderer/src/app/Onboarding.tsx b/src/renderer/src/app/Onboarding.tsx index b09d2512..87b38e01 100644 --- a/src/renderer/src/app/Onboarding.tsx +++ b/src/renderer/src/app/Onboarding.tsx @@ -198,9 +198,24 @@ export function Onboarding({ + {step === "connectors" ? ( +
+
+ {bootstrappedOrgId && ( + finishSetup(bootstrappedOrgId)} + /> + )} +
+ {error &&
{error}
} +
+ ) : (
- {restored && step !== "connectors" && ( + {restored && (

{t("Your progress was restored. Keys are never saved before launch, so enter yours again.")}

@@ -240,17 +255,10 @@ export function Onboarding({ onBack={back("mode")} /> )} - {step === "connectors" && bootstrappedOrgId && ( - finishSetup(bootstrappedOrgId)} - /> - )}
{error &&
{error}
}
+ )} ); diff --git a/src/renderer/src/app/OnboardingConnectorsStep.tsx b/src/renderer/src/app/OnboardingConnectorsStep.tsx index d21d43b2..198108bf 100644 --- a/src/renderer/src/app/OnboardingConnectorsStep.tsx +++ b/src/renderer/src/app/OnboardingConnectorsStep.tsx @@ -6,14 +6,16 @@ import type { AppSnapshot } from "../../../shared/domain"; import { Button } from "../shared/ui"; import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog"; import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog"; +import { IntegrationKeyRow } from "../pages/settings/IntegrationKeysPanel"; +import { connectorsEnabled } from "../shared/routes"; import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands"; import type { SnapshotActions } from "./useSnapshot"; import { t } from "../i18n/i18n"; /** - * The post-launch onboarding step: the company exists, so connections can be - * made against it. Connecting is optional — Finish enters the app either way - * and the Connectors page carries the same catalog. + * The post-launch onboarding step: the operator saves their own Composio and + * logo.dev keys, then connects the toolkits their Composio project enables. + * Skipping leaves Connectors out of the app until a Composio key is saved. */ export function ConnectorsStep({ orgId, @@ -26,6 +28,21 @@ export function ConnectorsStep({ actions: SnapshotActions; onFinish: () => void; }) { + const enabled = connectorsEnabled(snapshot); + const composioSavedAt = snapshot.integrationKeys.find((key) => key.name === "composio")?.updatedAt; + return ( +
+

{t("Connectors")}

+ {snapshot.integrationKeys.map((status) => )} + {enabled && } +
+ +
+
+ ); +} + +function ConnectorChoices({ orgId, snapshot, actions }: { orgId: string; snapshot: AppSnapshot; actions: SnapshotActions }) { const { catalog, busySlug, connectError, signIn, accountSync, syncAccounts, stopWaiting, checkAgain, connect, retry } = useConnectorCatalog(orgId, actions); const connectedSlugs = useMemo( () => @@ -38,8 +55,7 @@ export function ConnectorsStep({ ); return ( -
-

{t("Connectors")}

+ <> {catalog.phase === "error" && } {catalog.phase === "ready" && (
@@ -54,9 +70,6 @@ export function ConnectorsStep({ {signIn && } {connectError && } -
- -
-
+ ); } diff --git a/src/renderer/src/assets/provider-logos/README.md b/src/renderer/src/assets/provider-logos/README.md index ff05d585..eabcba2d 100644 --- a/src/renderer/src/assets/provider-logos/README.md +++ b/src/renderer/src/assets/provider-logos/README.md @@ -13,8 +13,7 @@ curl -o .png "https://img.logo.dev/?token= = { "Resume": "恢复", "Missing this run": "本次运行缺少的能力", "Disconnect": "断开连接", - "Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。", "Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。", "API key (if the server asks for one)": "API 密钥(服务需要时填写)", "Checking...": "检查中...", @@ -212,8 +211,6 @@ export const zh: Record = { "Powers the Connectors page and agent connector tools. Create one at platform.composio.dev.": "用于连接器页面和 Agent 的连接器工具。在 platform.composio.dev 创建。", "logo.dev publishable key": "logo.dev 公开密钥", "Shows brand logos for connectors. Create one at logo.dev.": "为连接器显示品牌图标。在 logo.dev 创建。", - "Google Antigravity OAuth client secret": "Google Antigravity OAuth 客户端密钥", - "Needed to sign in to and refresh the Google Antigravity provider.": "登录和续期 Google Antigravity 时需要。", "Saved": "已保存", "Not set": "未设置", "Replace key": "替换密钥", @@ -365,6 +362,7 @@ export const zh: Record = { "Filter risk": "筛选风险", "Filter tasks": "筛选任务", "Finish": "完成", + "Skip": "跳过", "Foreground": "前台", "General": "通用", "Get models": "获取模型", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index 79768e03..05cb45a2 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -115,7 +115,7 @@ function GoalCard({ return (
{t("Quarter goal")}
-
+
{goal ? goal.title : t("Set the first goal for {organizationName}.", { organizationName })}
diff --git a/src/renderer/src/pages/OfficePage.tsx b/src/renderer/src/pages/OfficePage.tsx index 869e6e84..b581b0fd 100644 --- a/src/renderer/src/pages/OfficePage.tsx +++ b/src/renderer/src/pages/OfficePage.tsx @@ -7,7 +7,7 @@ import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation import { AgentDetailCard } from "./office/AgentDetailCard"; import { officeStateLabel } from "../i18n/labels"; import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position"; -import { isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso"; +import { artScale, isoFrame, isoPixelSize, pickAt, toIso, type IsoFrame } from "./office/iso"; import { dayPart, season } from "../shared/palette-clock"; import { pixelScale } from "../shared/pixel-scale"; import type { Camera, OfficeAgentInput } from "./office/office-types"; @@ -119,6 +119,7 @@ export function OfficePage({ snapshot }: PageProps) { let raf = 0; let lastFrame = 0; const scene = document.createElement("canvas"); + const lightMap = document.createElement("canvas"); const tick = (now: number) => { if (!running) return; @@ -180,11 +181,12 @@ export function OfficePage({ snapshot }: PageProps) { }); } const clock = new Date(); - renderOffice(ctx, scene, { + renderOffice(ctx, scene, lightMap, { frame, plan: sim.plan, camera, pixel, + art: artScale(dpr), fontScale: pixelScale(dpr), viewW, viewH, diff --git a/src/renderer/src/pages/TeamPage.tsx b/src/renderer/src/pages/TeamPage.tsx index c7f4ee0f..bd102a7d 100644 --- a/src/renderer/src/pages/TeamPage.tsx +++ b/src/renderer/src/pages/TeamPage.tsx @@ -8,6 +8,7 @@ import { AvatarField } from "../shared/AvatarField"; import { TopbarActions } from "../shared/TopbarActions"; import { Dialog } from "../shared/Dialog"; import { OrgChartTree } from "./team/OrgChartTree"; +import { AgentModelField } from "./team/AgentModelField"; import type { AgentRecord, EmployeeRecord, ProviderCatalogEntry, ProviderRecord } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId, money } from "./types"; @@ -151,10 +152,12 @@ export function TeamPage({ snapshot, actions, focus }: PageProps) { {providers.map((provider) => )} - - provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} /> - - + provider.id === hireProviderId) ?? null} + entry={catalogEntry(snapshot.providerCatalog, providers, hireProviderId)} + value={hireModelId} + onChange={setHireModelId} + />

{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}

- - setModelId(event.currentTarget.value)} disabled={!providerId} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="edit-model-suggestions" /> - - + provider.id === providerId) ?? null} + entry={catalogEntry(catalog, providers, providerId)} + value={modelId} + onChange={setModelId} + /> ); } -/** Suggests only the picked provider's models; a model from another provider would mean nothing here. */ -function ModelSuggestions({ id, providerId, providers, catalog }: { id: string; providerId: string; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) { +/** The catalog entry of the provider picked now. */ +function catalogEntry(catalog: ProviderCatalogEntry[], providers: ProviderRecord[], providerId: string): ProviderCatalogEntry | null { const providerKey = providers.find((provider) => provider.id === providerId)?.providerKey; - const models = catalog.find((entry) => entry.key === providerKey)?.models ?? []; - return ( - - {models.map((model) => )} - - ); + return catalog.find((entry) => entry.key === providerKey) ?? null; } function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) { diff --git a/src/renderer/src/pages/office/iso.ts b/src/renderer/src/pages/office/iso.ts index 07e3a35a..d01780cf 100644 --- a/src/renderer/src/pages/office/iso.ts +++ b/src/renderer/src/pages/office/iso.ts @@ -57,13 +57,21 @@ export function drawBox(ctx: CanvasRenderingContext2D, frame: IsoFrame, foot: Re polygon(ctx, colors.south, [toIso(frame, { x: x0, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]); polygon(ctx, colors.east, [toIso(frame, { x: x1, y: y1 }, z), toIso(frame, { x: x1, y: y0 }, z), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top)]); polygon(ctx, colors.top, [toIso(frame, { x: x0, y: y0 }, top), toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y1 }, top), toIso(frame, { x: x0, y: y1 }, top)]); + if (height <= 0) return; + const rim = toIso(frame, { x: x1, y: y1 }, top); + polygon(ctx, "rgba(255,255,255,0.2)", [toIso(frame, { x: x0, y: y1 }, top), rim, { x: rim.x, y: rim.y + 0.5 }, toIso(frame, { x: x0, y: y1 }, top - 0.5)]); + polygon(ctx, "rgba(255,255,255,0.14)", [rim, toIso(frame, { x: x1, y: y0 }, top), toIso(frame, { x: x1, y: y0 }, top - 0.5), { x: rim.x, y: rim.y + 0.5 }]); + const base = toIso(frame, { x: x1, y: y1 }, z); + polygon(ctx, "rgba(255,255,255,0.1)", [{ x: rim.x - 0.5, y: rim.y }, rim, base, { x: base.x - 0.5, y: base.y }]); } function polygon(ctx: CanvasRenderingContext2D, color: string, points: Vec[]): void { + const art = Math.max(1, Math.round(ctx.getTransform().a)); + const snap = (value: number) => Math.round(value * art) / art; ctx.fillStyle = color; ctx.beginPath(); - ctx.moveTo(Math.round(points[0]!.x), Math.round(points[0]!.y)); - for (const point of points.slice(1)) ctx.lineTo(Math.round(point.x), Math.round(point.y)); + ctx.moveTo(snap(points[0]!.x), snap(points[0]!.y)); + for (const point of points.slice(1)) ctx.lineTo(snap(point.x), snap(point.y)); ctx.closePath(); ctx.fill(); } @@ -86,3 +94,8 @@ export function pickAt(frame: IsoFrame, people: T[], point: Vec): export function isoPixelSize(devicePixelRatio: number): number { return Math.max(1, Math.round(2 * devicePixelRatio)) / devicePixelRatio; } + +/** Scene pixels per iso pixel: two when an iso pixel spans an even number of device pixels, so detail stays on whole device pixels. */ +export function artScale(devicePixelRatio: number): number { + return Math.max(1, Math.round(2 * devicePixelRatio)) % 2 === 0 ? 2 : 1; +} diff --git a/src/renderer/src/pages/office/lighting.ts b/src/renderer/src/pages/office/lighting.ts new file mode 100644 index 00000000..78144042 --- /dev/null +++ b/src/renderer/src/pages/office/lighting.ts @@ -0,0 +1,141 @@ +import type { OfficeState } from "./agent-state"; +import type { Camera, FloorPlan, Rect, Vec } from "./office-types"; +import type { DayPart } from "../../shared/palette-clock"; +import { BACK_WALL_H, toIso, type IsoFrame } from "./iso"; +import { WALL } from "./floor-plan"; + +export interface Light { + at: Vec; + z: number; + /** Reach in iso pixels. */ + radius: number; + rgb: string; + strength: number; +} + +const AMBIENT: Record = { day: "rgb(236,232,224)", dawn: "rgb(214,190,196)", dusk: "rgb(222,184,150)", night: "rgb(58,68,112)" }; +const LAMPS_ON: Record = { day: 0.18, dawn: 0.6, dusk: 0.8, night: 1 }; +const SCREEN_RGB: Record = { running: "88,230,170", queued: "160,170,200", cooldown: "240,170,70", blocked: "240,80,80", idle: "110,130,170" }; +const WARM = "255,206,140"; +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]; +const LEVELS = 12; + +/** Every lamp and screen in the office: monitors glow in their agent's state colour, rooms and the open floor have warm lamps. */ +export function sceneLights(plan: FloorPlan, screens: Map): Light[] { + const lights: Light[] = []; + for (const [desk, state] of screens) { + lights.push({ at: { x: desk.x + desk.w / 2, y: desk.y + 14 }, z: 20, radius: 26, rgb: SCREEN_RGB[state], strength: state === "idle" ? 0.35 : 0.8 }); + } + for (const desk of plan.desks) lights.push({ at: { x: desk.bounds.x + desk.bounds.w / 2, y: desk.bounds.y + 60 }, z: 0, radius: 58, rgb: WARM, strength: 0.7 }); + for (const room of plan.rooms) { + const b = room.bounds; + const rgb = room.kind === "restroom" ? "205,228,255" : WARM; + lights.push({ at: { x: b.x + b.w / 2, y: b.y + b.h / 2 }, z: 0, radius: Math.max(b.w, b.h) * 0.42, rgb, strength: 0.85 }); + } + for (let x = 120; x < plan.world.w; x += 220) lights.push({ at: { x, y: 320 }, z: 0, radius: 64, rgb: WARM, strength: 0.75 }); + return lights; +} + +/** Sunlit floor under each window, longer and slanted toward evening or morning; none at night. */ +export function sunPatches(windows: Rect[], clock: Date): Vec[][] { + const hour = clock.getHours() + clock.getMinutes() / 60; + if (hour < 6.5 || hour > 18.5) return []; + const reach = 1.1 + Math.abs(hour - 12.5) * 0.35; + const slant = (12.5 - hour) * 0.18; + return windows.map((glass) => { + const low = (BACK_WALL_H - glass.y - glass.h) * reach; + const high = (BACK_WALL_H - glass.y) * reach; + return [ + { x: glass.x + slant * low, y: WALL + low }, + { x: glass.x + glass.w + slant * low, y: WALL + low }, + { x: glass.x + glass.w + slant * high, y: WALL + high }, + { x: glass.x + slant * high, y: WALL + high }, + ]; + }); +} + +/** Bands a light map into a few levels with a 4×4 ordered dither, so light falls off in pixels, not gradients. */ +export function ditherLevels(data: Uint8ClampedArray, width: number): void { + for (let i = 0; i < data.length; i += 4) { + const pixel = i / 4; + const threshold = BAYER[((Math.floor(pixel / width) & 3) << 2) | (pixel % width & 3)]! / 16 - 0.5; + for (let c = 0; c < 3; c++) { + const level = Math.round((data[i + c]! / 255) * LEVELS + threshold); + data[i + c] = (Math.max(0, Math.min(LEVELS, level)) / LEVELS) * 255; + } + } +} + +export interface LightingInput { + frame: IsoFrame; + camera: Camera; + /** Scene pixels per iso pixel. */ + art: number; + dayPart: DayPart; + lights: Light[]; + patches: Vec[][]; + timeMs: number; +} + +/** Multiplies the finished scene by a light map drawn at iso resolution: ambient for the hour, lamps and screens added, sun through the windows. */ +export function applyLighting(scene: CanvasRenderingContext2D, map: HTMLCanvasElement, input: LightingInput): void { + const w = Math.ceil(scene.canvas.width / input.art); + const h = Math.ceil(scene.canvas.height / input.art); + if (map.width !== w || map.height !== h) { + map.width = w; + map.height = h; + } + const ctx = map.getContext("2d", { willReadFrequently: true }); + if (!ctx) throw new Error("The light map canvas has no 2D context."); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = AMBIENT[input.dayPart]; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = "lighter"; + const lamps = LAMPS_ON[input.dayPart]; + const view = (p: Vec, z = 0) => { + const iso = toIso(input.frame, p, z); + return { x: iso.x - input.camera.x, y: iso.y - input.camera.y }; + }; + for (const light of input.lights) { + const at = view(light.at, light.z); + if (at.x < -light.radius || at.y < -light.radius || at.x > w + light.radius || at.y > h + light.radius) continue; + const glow = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, light.radius); + glow.addColorStop(0, `rgba(${light.rgb},${0.55 * light.strength * lamps})`); + glow.addColorStop(1, `rgba(${light.rgb},0)`); + ctx.fillStyle = glow; + ctx.fillRect(at.x - light.radius, at.y - light.radius, light.radius * 2, light.radius * 2); + } + ctx.fillStyle = input.dayPart === "day" ? "rgba(255,240,205,0.16)" : "rgba(255,196,140,0.12)"; + for (const patch of input.patches) { + ctx.beginPath(); + patch.map((p) => view(p)).forEach((p, index) => (index === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y))); + ctx.closePath(); + ctx.fill(); + } + const image = ctx.getImageData(0, 0, w, h); + ditherLevels(image.data, w); + ctx.putImageData(image, 0, 0); + scene.save(); + scene.setTransform(input.art, 0, 0, input.art, 0, 0); + scene.imageSmoothingEnabled = false; + scene.globalCompositeOperation = "multiply"; + scene.drawImage(map, 0, 0); + scene.globalCompositeOperation = "source-over"; + drawDust(scene, input, view); + scene.restore(); +} + +/** Dust motes drifting up through the sunlit patches. */ +function drawDust(scene: CanvasRenderingContext2D, input: LightingInput, view: (p: Vec, z?: number) => Vec): void { + scene.fillStyle = "rgba(255,246,220,0.75)"; + input.patches.forEach((patch, index) => { + const centre = { x: (patch[0]!.x + patch[2]!.x) / 2, y: (patch[0]!.y + patch[2]!.y) / 2 }; + for (let mote = 0; mote < 5; mote++) { + const seed = index * 7 + mote * 13; + const rise = ((input.timeMs / 90 + seed * 11) % 40); + const drift = Math.sin(input.timeMs / 1400 + seed) * 6; + const at = view({ x: centre.x + ((seed * 37) % 50) - 25 + drift, y: centre.y + ((seed * 17) % 30) - 15 }, rise); + scene.fillRect(Math.round(at.x * 2) / 2, Math.round(at.y * 2) / 2, 0.5, 0.5); + } + }); +} diff --git a/src/renderer/src/pages/office/office-renderer.ts b/src/renderer/src/pages/office/office-renderer.ts index 4e7535e7..11d3c5f8 100644 --- a/src/renderer/src/pages/office/office-renderer.ts +++ b/src/renderer/src/pages/office/office-renderer.ts @@ -1,7 +1,8 @@ import type { Camera, FloorPlan, Rect } from "./office-types"; import type { OfficeState } from "./agent-state"; -import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer"; +import { drawBackWalls, drawContactShadows, drawGround, sceneDrawables, windowRects, type Drawable } from "./scene-renderer"; import { depthOf, toIso, type IsoFrame } from "./iso"; +import { applyLighting, sceneLights, sunPatches } from "./lighting"; import { skyScene, weatherFor } from "./sky"; import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites"; import type { DayPart, Season } from "../../shared/palette-clock"; @@ -30,6 +31,8 @@ export interface OfficeFrame { camera: Camera; /** CSS px per iso pixel. */ pixel: number; + /** Scene pixels per iso pixel (artScale). */ + art: number; /** CSS px per pixel-font pixel, for the crisp text drawn over the scene. */ fontScale: number; viewW: number; @@ -50,24 +53,16 @@ const STATE_MARK: Partial> = { blocked: "#ef4444", }; -/** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */ -const DAY_TINT: Record = { - dawn: "#f3d9d6", - day: null, - dusk: "#f0c6a0", - night: "#7d8bc0", -}; - export function hashString(value: string): number { let hash = 0; for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0; return Math.abs(hash); } -/** Draws the scene at iso-pixel resolution on `scene`, depth-sorted, then scales it up unsmoothed onto `ctx` and writes labels in the pixel font. */ -export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement, input: OfficeFrame): void { - const sceneW = Math.ceil(input.viewW / input.pixel); - const sceneH = Math.ceil(input.viewH / input.pixel); +/** Draws the scene at art resolution on `scene`, depth-sorted and lit for the hour, then scales it up unsmoothed onto `ctx` and writes labels in the pixel font. */ +export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasElement, lightMap: HTMLCanvasElement, input: OfficeFrame): void { + const sceneW = Math.ceil((input.viewW / input.pixel) * input.art); + const sceneH = Math.ceil((input.viewH / input.pixel) * input.art); if (scene.width !== sceneW || scene.height !== sceneH) { scene.width = sceneW; scene.height = sceneH; @@ -75,29 +70,39 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle const sctx = scene.getContext("2d"); if (!sctx) throw new Error("The office scene canvas has no 2D context."); sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.imageSmoothingEnabled = false; sctx.fillStyle = "#14161c"; sctx.fillRect(0, 0, sceneW, sceneH); - sctx.translate(-Math.round(input.camera.x), -Math.round(input.camera.y)); + const camera = snappedCamera(input); + sctx.setTransform(input.art, 0, 0, input.art, -camera.x * input.art, -camera.y * input.art); drawGround(sctx, input.frame, input.plan); drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs }); const screens = new Map(input.people.filter((person) => person.desk).map((person) => [person.desk!, person.state])); - const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs), ...input.people.map((person) => personDrawable(input, person))]; + const scenery = sceneDrawables(input.frame, input.plan, input.season, screens, input.timeMs); + drawContactShadows(sctx, input.frame, scenery); + const items: Drawable[] = [...scenery, ...input.people.map((person) => personDrawable(input, person))]; items.sort((a, b) => a.depth - b.depth); for (const item of items) item.draw(sctx); - const tint = DAY_TINT[input.dayPart]; - if (tint) { - sctx.setTransform(1, 0, 0, 1, 0, 0); - sctx.globalCompositeOperation = "multiply"; - sctx.fillStyle = tint; - sctx.fillRect(0, 0, sceneW, sceneH); - sctx.globalCompositeOperation = "source-over"; - } + applyLighting(sctx, lightMap, { + frame: input.frame, + camera, + art: input.art, + dayPart: input.dayPart, + lights: sceneLights(input.plan, screens), + patches: sunPatches(input.plan.rooms.flatMap(windowRects), input.clock), + timeMs: input.timeMs, + }); ctx.imageSmoothingEnabled = false; - ctx.drawImage(scene, 0, 0, sceneW * input.pixel, sceneH * input.pixel); - drawLabels(ctx, input); + ctx.drawImage(scene, 0, 0, (sceneW * input.pixel) / input.art, (sceneH * input.pixel) / input.art); + drawLabels(ctx, input, camera); +} + +/** The camera on the scene's pixel grid, so the scene and the labels over it move together. */ +function snappedCamera(input: OfficeFrame): Camera { + return { x: Math.round(input.camera.x * input.art) / input.art, y: Math.round(input.camera.y * input.art) / input.art }; } function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable { @@ -106,12 +111,7 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable { depth: depthOf(person) + 0.5, draw: (ctx) => { const feet = toIso(input.frame, person); - if (person.id === input.selectedId) { - ctx.fillStyle = "rgba(94,106,210,0.9)"; - ctx.fillRect(Math.round(feet.x) - 12, Math.round(feet.y), 24, 2); - ctx.fillRect(Math.round(feet.x) - 14, Math.round(feet.y) - 2, 2, 2); - ctx.fillRect(Math.round(feet.x) + 12, Math.round(feet.y) - 2, 2, 2); - } + if (person.id === input.selectedId) drawSelection(ctx, feet.x, feet.y, input.timeMs); const working = person.state === "running" && person.walkPhase === 0; drawPerson(ctx, feet.x, feet.y, person.appearance, { facing: person.facing, @@ -121,24 +121,47 @@ function personDrawable(input: OfficeFrame, person: RenderPerson): Drawable { timeMs: input.timeMs, animSeed: person.animSeed, }, person.isCeo); - const dot = STATE_MARK[person.state]; - if (dot) { - ctx.fillStyle = dot; - ctx.fillRect(Math.round(feet.x) + 8, Math.round(feet.y) - 40, 4, 4); - } + const gem = STATE_MARK[person.state]; + if (gem) drawStateGem(ctx, feet.x, feet.y - 38 + Math.round(Math.sin(input.timeMs / 420 + person.animSeed) * 2) / 2, gem); }, }; } +/** A small faceted gem floating over the head, in the state's colour. */ +function drawStateGem(ctx: CanvasRenderingContext2D, x: number, y: number, color: string): void { + const cx = Math.round(x * 2) / 2; + const rows = [0.5, 1.5, 2.5, 2.5, 1.5, 1, 0.5]; + ctx.fillStyle = "rgba(20,18,24,0.85)"; + rows.forEach((half, row) => ctx.fillRect(cx - half - 0.5, y + row * 0.5 - 0.5, half * 2 + 1, 1)); + ctx.fillStyle = color; + rows.forEach((half, row) => ctx.fillRect(cx - half, y + row * 0.5, half * 2, 0.5)); + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.fillRect(cx - 1, y + 1, 0.5, 0.5); + ctx.fillRect(cx - 0.5, y + 0.5, 0.5, 0.5); +} + +/** A pixel ring around the selected person's feet, breathing in and out. */ +function drawSelection(ctx: CanvasRenderingContext2D, x: number, y: number, timeMs: number): void { + const grow = Math.round((Math.sin(timeMs / 260) + 1) * 0.5) * 0.5; + const cx = Math.round(x * 2) / 2; + const cy = Math.round(y * 2) / 2; + const rx = 9 + grow; + ctx.fillStyle = "rgba(124,138,240,0.95)"; + for (let step = 0; step < 48; step++) { + const angle = (step / 48) * 2 * Math.PI; + ctx.fillRect(Math.round((cx + Math.cos(angle) * rx) * 2) / 2, Math.round((cy + Math.sin(angle) * rx * 0.5) * 2) / 2, 0.5, 0.5); + } +} + /** Names under people and room signs over doors, in the pixel font at screen resolution so they stay crisp. */ -function drawLabels(ctx: CanvasRenderingContext2D, input: OfficeFrame): void { +function drawLabels(ctx: CanvasRenderingContext2D, input: OfficeFrame, camera: Camera): void { const size = 12 * input.fontScale; ctx.font = `${size}px "Fusion Pixel"`; ctx.textAlign = "center"; ctx.textBaseline = "top"; const screen = (x: number, y: number, z = 0) => { const iso = toIso(input.frame, { x, y }, z); - return { x: Math.round((iso.x - Math.round(input.camera.x)) * input.pixel), y: Math.round((iso.y - Math.round(input.camera.y)) * input.pixel) }; + return { x: Math.round((iso.x - camera.x) * input.pixel), y: Math.round((iso.y - camera.y) * input.pixel) }; }; for (const room of input.plan.rooms) { const at = screen(room.doorCx, room.bounds.y + room.bounds.h, 22); diff --git a/src/renderer/src/pages/office/person-art.ts b/src/renderer/src/pages/office/person-art.ts new file mode 100644 index 00000000..4a0f5c54 --- /dev/null +++ b/src/renderer/src/pages/office/person-art.ts @@ -0,0 +1,311 @@ +import { finishSprite, mix, PixelGrid, tone } from "./pixel-grid"; +import type { Carrying, Facing, SpriteAppearance } from "./sprites"; + +/** Sprite canvas in art pixels (two per iso pixel); the feet rest on ANCHOR. */ +export const ART_W = 40; +export const ART_H = 80; +export const ANCHOR = { x: 20, y: 74 }; + +/** One drawn pose: walk step 0-3 (-1 standing), typing hands 0/1 (-1 not typing), eyes shut mid-blink. */ +export interface PersonFrame { + facing: Facing; + step: number; + typing: number; + blink: boolean; + carrying: Carrying; +} + +const EYE = "#2a2430"; +const WHITE = "#fbfaf6"; +const SHIRT_WHITE = "#ecebe6"; +const BELT = "#2b2a30"; +const BUCKLE = "#c9b27a"; +const MOUTH = "#9c4d48"; +const BLUSH = "#e88a8a"; +const BLAZER = "#37415c"; +const BLAZER_CEO = "#262c3d"; + +export function personArt(look: SpriteAppearance, frame: PersonFrame, isCeo: boolean): PixelGrid { + const grid = new PixelGrid(ART_W, ART_H); + const side = frame.facing === "east" || frame.facing === "west"; + const lift = frame.step === 1 || frame.step === 3 ? -1 : 0; + if (side) drawSide(grid, look, frame, isCeo, lift); + else drawFrontOrBack(grid, look, frame, isCeo, lift, frame.facing === "south"); + finishSprite(grid); + return frame.facing === "west" ? grid.mirrored() : grid; +} + +function jacketOf(look: SpriteAppearance, isCeo: boolean): string { + return look.outfit === "blazer" ? (isCeo ? BLAZER_CEO : BLAZER) : look.shirt; +} + +function drawFrontOrBack(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, isCeo: boolean, lift: number, front: boolean): void { + const leftUp = frame.step === 0 ? 2 : 0; + const rightUp = frame.step === 2 ? 2 : 0; + legs(g, look, 12, 53, 7, leftUp, front); + legs(g, look, 21, 53, 7, rightUp, front); + g.rect(19, 53, 2, 4, look.pants); + const y = lift; + const body = jacketOf(look, isCeo); + g.rect(11, 33 + y, 18, 18, body); + g.clear(11, 33 + y); + g.clear(28, 33 + y); + g.rect(11, 51 + y, 18, 2, BELT); + if (front) g.rect(19, 51 + y, 2, 2, BUCKLE); + const swing = frame.step < 0 ? 0 : frame.step === 0 ? 1 : frame.step === 2 ? -1 : 0; + if (frame.typing >= 0) typingArms(g, look, body, y, frame.typing); + else { + arm(g, look, body, 8, 34 + y + swing); + arm(g, look, body, 29, 34 + y - swing); + } + if (front) frontOutfit(g, look, body, y); + else backOutfit(g, look, body, y); + g.rect(17, 31 + y, 6, 2, tone(look.skin, 0.84)); + head(g, look, frame, y, front); + if (frame.carrying && frame.typing < 0) carried(g, frame.carrying, 31, 44 + y - swing); +} + +function legs(g: PixelGrid, look: SpriteAppearance, x: number, top: number, w: number, up: number, front: boolean): void { + g.rect(x, top, w, 17 - up, look.pants); + g.rect(x, top + 6, 1, 8 - up, tone(look.pants, 0.85)); + const shoeY = 70 - up; + g.rect(x - 1, shoeY, w + 1, 3, look.shoes); + g.rect(x - 1, shoeY + 3, w + 1, 1, tone(look.shoes, 0.6)); + if (front) g.rect(x, shoeY, 2, 1, tone(look.shoes, 1.4)); +} + +function arm(g: PixelGrid, look: SpriteAppearance, body: string, x: number, top: number): void { + const sleeve = look.outfit === "tee" ? 6 : 14; + g.rect(x, top, 3, sleeve, body); + g.rect(x, top + sleeve, 3, 15 - sleeve, look.skin); + if (look.outfit === "blazer") g.rect(x, top + 13, 3, 1, SHIRT_WHITE); + if (look.outfit === "hoodie") g.rect(x, top + 13, 3, 1, tone(body, 0.78)); + g.rect(x, top + 15, 3, 3, look.skin); + g.px(x + 1, top + 17, tone(look.skin, 0.82)); +} + +function typingArms(g: PixelGrid, look: SpriteAppearance, body: string, y: number, beat: number): void { + for (const [x, inward, offset] of [[8, 1, beat], [29, -1, 1 - beat]] as const) { + g.rect(x, 34 + y, 3, 9, body); + g.rect(x + inward * 2, 41 + y - offset, 3, 4, body); + g.rect(x + inward * 3, 44 + y - offset, 3, 2, look.skin); + } +} + +function frontOutfit(g: PixelGrid, look: SpriteAppearance, body: string, y: number): void { + if (look.outfit === "tee") { + g.rect(18, 33 + y, 4, 1, look.skin); + g.rect(19, 34 + y, 2, 1, look.skin); + g.rect(22, 38 + y, 3, 2, look.accent); + g.rect(11, 50 + y, 18, 1, tone(body, 0.85)); + return; + } + if (look.outfit === "hoodie") { + g.rect(13, 33 + y, 14, 2, tone(body, 0.78)); + g.rect(17, 35 + y, 1, 5, WHITE); + g.rect(22, 35 + y, 1, 5, WHITE); + g.rect(14, 43 + y, 12, 6, tone(body, 0.88)); + g.rect(14, 43 + y, 12, 1, tone(body, 0.74)); + return; + } + for (let row = 0; row < 12; row++) { + const half = Math.max(1, 3 - Math.floor(row / 3)); + g.rect(20 - half, 33 + y + row, half * 2, 1, SHIRT_WHITE); + g.px(19 - half, 33 + y + row, tone(body, 0.72)); + g.px(20 + half, 33 + y + row, tone(body, 0.72)); + } + g.rect(19, 34 + y, 2, 1, tone(look.accent, 0.8)); + g.rect(19, 35 + y, 2, 9, look.accent); + g.px(19, 44 + y, tone(look.accent, 0.8)); + g.px(21, 47 + y, "#d8d0b8"); + g.px(21, 49 + y, "#d8d0b8"); + g.rect(24, 37 + y, 2, 1, look.accent); +} + +function backOutfit(g: PixelGrid, look: SpriteAppearance, body: string, y: number): void { + if (look.outfit === "hoodie") g.rect(14, 33 + y, 12, 5, tone(body, 0.82)); + if (look.outfit === "blazer") g.rect(19, 44 + y, 1, 7, tone(body, 0.7)); + if (look.outfit === "tee") g.rect(11, 50 + y, 18, 1, tone(body, 0.85)); +} + +function head(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, y: number, front: boolean): void { + const top = 12 + y; + g.rect(11, top, 18, 19, look.skin); + for (const [x, dy] of [[11, 0], [12, 0], [11, 1], [28, 0], [27, 0], [28, 1], [11, 18], [28, 18]] as const) g.clear(x, top + dy); + g.rect(10, top + 8, 1, 4, tone(look.skin, 0.9)); + g.rect(29, top + 8, 1, 4, tone(look.skin, 0.9)); + if (front) { + face(g, look, frame.blink, top); + frontHair(g, look, top); + } else backHair(g, look, top); +} + +function face(g: PixelGrid, look: SpriteAppearance, blink: boolean, top: number): void { + const brow = tone(look.hair, 0.8); + g.rect(13, top + 6, 3, 1, brow); + g.rect(24, top + 6, 3, 1, brow); + if (look.glasses) { + const lens = mix(look.skin, "#9fd0f0", 0.35); + for (const x of [13, 23]) { + g.rect(x, top + 7, 4, 5, "#1c1c22"); + g.rect(x + 1, top + 8, 2, 3, lens); + } + g.rect(17, top + 8, 6, 1, "#1c1c22"); + } + for (const x of [14, 24]) { + if (blink) g.rect(x, top + 10, 2, 1, EYE); + else { + g.rect(x, top + 8, 2, 3, EYE); + g.px(x + 1, top + 8, WHITE); + } + } + g.px(20, top + 12, tone(look.skin, 0.8)); + g.rect(18, top + 15, 4, 1, MOUTH); + g.px(17, top + 14, mix(look.skin, MOUTH, 0.5)); + g.px(22, top + 14, mix(look.skin, MOUTH, 0.5)); + const blush = mix(look.skin, BLUSH, 0.45); + g.rect(13, top + 13, 2, 1, blush); + g.rect(25, top + 13, 2, 1, blush); +} + +function cap(g: PixelGrid, look: SpriteAppearance, top: number, rows: number): void { + g.rect(12, top - 2, 16, 1, look.hair); + g.rect(11, top - 1, 18, rows + 1, look.hair); + g.rect(14, top - 1, 4, 1, tone(look.hair, 1.4)); +} + +function frontHair(g: PixelGrid, look: SpriteAppearance, top: number): void { + const hair = look.hair; + switch (look.hairStyle) { + case "bald": + g.rect(15, top + 1, 3, 1, tone(look.skin, 1.12)); + return; + case "buzz": + g.rect(12, top - 1, 16, 3, hair); + g.rect(11, top + 2, 1, 4, hair); + g.rect(28, top + 2, 1, 4, hair); + return; + case "short": + cap(g, look, top, 4); + g.rect(12, top + 4, 7, 1, hair); + g.rect(11, top + 4, 2, 5, hair); + g.rect(27, top + 4, 2, 5, hair); + return; + case "bob": + cap(g, look, top, 5); + g.rect(9, top + 2, 4, 14, hair); + g.rect(27, top + 2, 4, 14, hair); + g.rect(13, top + 5, 14, 1, tone(hair, 0.85)); + return; + case "long": + cap(g, look, top, 4); + g.rect(9, top + 2, 4, 25, hair); + g.rect(27, top + 2, 4, 25, hair); + g.rect(13, top + 4, 8, 1, hair); + g.rect(10, top + 8, 1, 14, tone(hair, 1.25)); + return; + case "ponytail": + cap(g, look, top, 4); + g.rect(21, top + 4, 6, 1, hair); + g.rect(29, top + 2, 3, 12, hair); + g.rect(29, top + 2, 3, 1, tone(look.accent, 0.9)); + return; + case "curly": + cap(g, look, top - 1, 5); + for (let x = 10; x <= 29; x += 2) g.rect(x, top - 4 + (x % 4 === 0 ? 0 : 1), 2, 2, hair); + for (let row = 2; row < 12; row += 2) { + g.rect(9 + (row % 4 === 0 ? 0 : 1), top + row, 3, 2, hair); + g.rect(28 - (row % 4 === 0 ? 0 : 1), top + row, 3, 2, hair); + } + g.rect(13, top + 4, 14, 1, tone(hair, 0.85)); + return; + } +} + +function backHair(g: PixelGrid, look: SpriteAppearance, top: number): void { + const hair = look.hair; + if (look.hairStyle === "bald") return; + const depth = { buzz: 10, short: 14, bob: 18, long: 18, ponytail: 15, curly: 15 }[look.hairStyle]; + g.rect(12, top - 2, 16, 1, hair); + g.rect(11, top - 1, 18, depth, hair); + g.rect(14, top, 5, 1, tone(hair, 1.4)); + if (look.hairStyle === "long") g.rect(10, top + 14, 20, 12, hair); + if (look.hairStyle === "bob") g.rect(10, top + 12, 20, 4, hair); + if (look.hairStyle === "ponytail") { + g.rect(18, top + 13, 4, 1, look.accent); + g.rect(18, top + 14, 4, 11, hair); + g.rect(19, top + 25, 2, 2, hair); + } + if (look.hairStyle === "curly") for (let x = 11; x <= 28; x += 3) g.rect(x, top + 14, 2, 2, hair); +} + +function drawSide(g: PixelGrid, look: SpriteAppearance, frame: PersonFrame, isCeo: boolean, lift: number): void { + const stride = frame.step === 0 ? 3 : frame.step === 2 ? -3 : 0; + sideLeg(g, look, 15 - stride, 0.82); + sideLeg(g, look, 20 + stride, 1); + const y = lift; + const body = jacketOf(look, isCeo); + g.rect(13, 33 + y, 14, 18, body); + g.clear(13, 33 + y); + g.clear(26, 33 + y); + g.rect(13, 51 + y, 14, 2, BELT); + if (look.outfit === "hoodie") g.rect(13, 33 + y, 5, 6, tone(body, 0.8)); + if (look.outfit === "blazer") { + g.rect(24, 33 + y, 3, 9, SHIRT_WHITE); + g.rect(25, 35 + y, 2, 8, look.accent); + } + if (look.outfit === "tee") g.rect(22, 38 + y, 3, 2, look.accent); + const swing = frame.typing >= 0 ? 4 : -stride; + arm(g, look, body, 18 + Math.round(swing / 2), 34 + y); + g.rect(17, 31 + y, 5, 2, tone(look.skin, 0.84)); + sideHead(g, look, frame.blink, 12 + y); + if (frame.carrying && frame.typing < 0) carried(g, frame.carrying, 22 + Math.round(swing / 2), 46 + y); +} + +function sideLeg(g: PixelGrid, look: SpriteAppearance, x: number, shade: number): void { + g.rect(x, 53, 5, 17, tone(look.pants, shade)); + g.rect(x, 70, 7, 3, tone(look.shoes, shade)); + g.rect(x, 73, 7, 1, tone(look.shoes, 0.6 * shade)); +} + +function sideHead(g: PixelGrid, look: SpriteAppearance, blink: boolean, top: number): void { + g.rect(12, top, 16, 19, look.skin); + for (const [x, dy] of [[12, 0], [13, 0], [12, 1], [27, 0], [26, 0], [27, 1], [12, 18], [27, 18]] as const) g.clear(x, top + dy); + g.rect(28, top + 10, 1, 3, look.skin); + g.rect(17, top + 8, 2, 4, tone(look.skin, 0.86)); + g.rect(21, top + 6, 4, 1, tone(look.hair, 0.8)); + if (blink) g.rect(22, top + 10, 2, 1, EYE); + else { + g.rect(22, top + 8, 2, 3, EYE); + g.px(23, top + 8, WHITE); + } + if (look.glasses) { + g.rect(21, top + 7, 4, 1, "#1c1c22"); + g.rect(21, top + 11, 4, 1, "#1c1c22"); + g.rect(16, top + 8, 5, 1, "#1c1c22"); + } + g.rect(24, top + 15, 3, 1, MOUTH); + g.rect(24, top + 13, 2, 1, mix(look.skin, BLUSH, 0.45)); + if (look.hairStyle === "bald") return; + const hair = look.hair; + const back = { buzz: 8, short: 12, bob: 16, long: 16, ponytail: 12, curly: 14 }[look.hairStyle]; + g.rect(13, top - 2, 13, 1, hair); + g.rect(12, top - 1, 15, look.hairStyle === "buzz" ? 3 : 5, hair); + g.rect(11, top, 7, back, hair); + g.rect(15, top - 1, 5, 1, tone(hair, 1.4)); + if (look.hairStyle === "long") g.rect(10, top + 12, 8, 14, hair); + if (look.hairStyle === "bob") g.rect(25, top + 4, 2, 2, hair); + if (look.hairStyle === "ponytail") g.rect(8, top + 4, 4, 12, hair); + if (look.hairStyle === "curly") for (let row = 0; row < 14; row += 3) g.rect(10, top + row, 2, 2, hair); +} + +function carried(g: PixelGrid, item: NonNullable, x: number, y: number): void { + if (item === "coffee") { + g.rect(x, y, 4, 5, WHITE); + g.rect(x, y, 4, 1, "#6b4a2f"); + g.px(x + 4, y + 2, WHITE); + return; + } + g.rect(x, y + 1, 5, 4, "#c9553e"); + g.rect(x + 1, y + 2, 3, 2, "#e8b84a"); +} diff --git a/src/renderer/src/pages/office/pixel-grid.ts b/src/renderer/src/pages/office/pixel-grid.ts new file mode 100644 index 00000000..f32f3cea --- /dev/null +++ b/src/renderer/src/pages/office/pixel-grid.ts @@ -0,0 +1,96 @@ +/** An RGBA pixel buffer drawn without anti-aliasing, so procedural art stays exact. */ +export class PixelGrid { + readonly data: Uint8ClampedArray; + + constructor(readonly w: number, readonly h: number) { + this.data = new Uint8ClampedArray(new ArrayBuffer(w * h * 4)); + } + + rect(x: number, y: number, w: number, h: number, color: string): void { + const [r, g, b] = rgb(color); + for (let py = Math.max(0, y); py < Math.min(this.h, y + h); py++) { + for (let px = Math.max(0, x); px < Math.min(this.w, x + w); px++) this.set(px, py, r, g, b, 255); + } + } + + px(x: number, y: number, color: string): void { + this.rect(x, y, 1, 1, color); + } + + clear(x: number, y: number): void { + if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.data[(y * this.w + x) * 4 + 3] = 0; + } + + alpha(x: number, y: number): number { + return x < 0 || y < 0 || x >= this.w || y >= this.h ? 0 : this.data[(y * this.w + x) * 4 + 3]!; + } + + mirrored(): PixelGrid { + const out = new PixelGrid(this.w, this.h); + for (let y = 0; y < this.h; y++) { + for (let x = 0; x < this.w; x++) { + const from = (y * this.w + x) * 4; + out.data.set(this.data.subarray(from, from + 4), (y * this.w + (this.w - 1 - x)) * 4); + } + } + return out; + } + + private set(x: number, y: number, r: number, g: number, b: number, a: number): void { + const at = (y * this.w + x) * 4; + this.data[at] = r; + this.data[at + 1] = g; + this.data[at + 2] = b; + this.data[at + 3] = a; + } +} + +const NEIGHBOURS = [[1, 0], [0, 1], [-1, 0], [0, -1]] as const; + +/** Light from the top left: rim-lit upper-left edges, shaded lower-right edges, then a selective outline in a dark tone of the colour it borders. */ +export function finishSprite(grid: PixelGrid): void { + const source = grid.data.slice(); + const opaque = (x: number, y: number) => x >= 0 && y >= 0 && x < grid.w && y < grid.h && source[(y * grid.w + x) * 4 + 3]! > 0; + for (let y = 0; y < grid.h; y++) { + for (let x = 0; x < grid.w; x++) { + const at = (y * grid.w + x) * 4; + if (opaque(x, y)) { + const factor = !opaque(x - 1, y) || !opaque(x, y - 1) ? 1.14 : !opaque(x + 1, y) || !opaque(x, y + 1) ? 0.84 : 1; + for (let c = 0; c < 3; c++) grid.data[at + c] = source[at + c]! * factor; + continue; + } + for (const [dx, dy] of NEIGHBOURS) { + if (!opaque(x + dx, y + dy)) continue; + const from = ((y + dy) * grid.w + x + dx) * 4; + for (let c = 0; c < 3; c++) grid.data[at + c] = source[from + c]! * 0.32; + grid.data[at + 3] = 255; + break; + } + } + } +} + +/** Mixes two colours; `amount` 0 keeps the first. */ +export function mix(a: string, b: string, amount: number): string { + const [ar, ag, ab] = rgb(a); + const [br, bg, bb] = rgb(b); + const channel = (from: number, to: number) => Math.round(from + (to - from) * amount); + return hex(channel(ar, br), channel(ag, bg), channel(ab, bb)); +} + +/** Scales a colour's channels; below 1 darkens. */ +export function tone(color: string, factor: number): string { + const [r, g, b] = rgb(color); + const channel = (value: number) => Math.max(0, Math.min(255, Math.round(value * factor))); + return hex(channel(r), channel(g), channel(b)); +} + +function rgb(color: string): [number, number, number] { + if (!/^#[0-9a-f]{6}$/i.test(color)) throw new Error(`Sprite colours are #rrggbb, not "${color}".`); + const value = Number.parseInt(color.slice(1), 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +function hex(r: number, g: number, b: number): string { + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; +} diff --git a/src/renderer/src/pages/office/scene-renderer.ts b/src/renderer/src/pages/office/scene-renderer.ts index 1f5f9182..203f8b55 100644 --- a/src/renderer/src/pages/office/scene-renderer.ts +++ b/src/renderer/src/pages/office/scene-renderer.ts @@ -12,9 +12,10 @@ export interface Drawable { draw: (ctx: CanvasRenderingContext2D) => void; } -/** A piece of the scene and the plan footprint it sorts by. */ +/** A piece of the scene and the plan footprint it sorts by; grounded pieces cast a contact shadow. */ export interface SceneDrawable extends Drawable { foot: Rect; + grounded: boolean; } /** Interior walls are cut away to this height so rooms stay visible. */ @@ -23,7 +24,6 @@ const INNER_WALL_H = 14; const PIECE = 32; const PALETTE = { - wood: "#b08a62", woodSeam: "#9c7852", corridor: "#a8815c", wall: "#6b5f52", @@ -45,6 +45,8 @@ const PALETTE = { panelSeam: "#5d5246", } as const; +const PLANKS = ["#b08a62", "#b8936a", "#a9845c", "#b48e65"]; + const LEAVES: Record = { spring: { leaf: "#5f9a52", light: "#e9a3c2" }, summer: { leaf: "#3f7f3a", light: "#6fb25c" }, @@ -62,15 +64,27 @@ export function drawGround(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: ctx.save(); setGroundTransform(ctx, frame); const { w, h } = plan.world; - ctx.fillStyle = PALETTE.wood; - ctx.fillRect(0, 0, w, h); - ctx.fillStyle = PALETTE.woodSeam; + ctx.beginPath(); + ctx.rect(0, 0, w, h); + ctx.clip(); for (let y = 0; y < h; y += 16) { + for (let x = (y / 16) % 2 === 0 ? -48 : 0; x < w; x += 96) { + const grain = (Math.imul(x + 977, 31) ^ Math.imul(y + 131, 17)) >>> 0; + ctx.fillStyle = PLANKS[grain % PLANKS.length]!; + ctx.fillRect(x, y, 96, 16); + ctx.fillStyle = PALETTE.woodSeam; + ctx.fillRect(x + 8 + (grain % 40), y + 4 + (grain % 3) * 3, 22 + (grain % 17), 1); + ctx.fillRect(x + 50 + (grain % 23), y + 10 - (grain % 2) * 4, 14, 1); + ctx.fillRect(x, y, 1, 16); + } + ctx.fillStyle = PALETTE.woodSeam; ctx.fillRect(0, y, w, 1); - for (let x = (y / 16) % 2 === 0 ? 0 : 48; x < w; x += 96) ctx.fillRect(x, y, 1, 16); } ctx.fillStyle = PALETTE.corridor; ctx.fillRect(WALL, 288, w - 2 * WALL, 64); + ctx.fillStyle = "#93704f"; + ctx.fillRect(WALL, 292, w - 2 * WALL, 1); + ctx.fillRect(WALL, 347, w - 2 * WALL, 1); for (const room of plan.rooms) drawRoomFloor(ctx, room); ctx.restore(); } @@ -83,6 +97,10 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void { ctx.strokeStyle = room.kind === "ceo" ? PALETTE.ceoCarpetEdge : PALETTE.meetingCarpetEdge; ctx.lineWidth = 2; ctx.strokeRect(b.x + 6, b.y + 6, b.w - 12, b.h - 12); + ctx.fillStyle = room.kind === "ceo" ? "#66749e" : "#769a82"; + for (let ty = b.y + 16; ty < b.y + b.h - 12; ty += 12) { + for (let tx = b.x + 16 + (((ty - b.y) / 12) % 2) * 6; tx < b.x + b.w - 12; tx += 12) ctx.fillRect(tx, ty, 2, 2); + } return; } const [tileA, tileB] = room.kind === "snack" ? [PALETTE.snackTileA, PALETTE.snackTileB] : [PALETTE.restTileA, PALETTE.restTileB]; @@ -92,6 +110,18 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void { ctx.fillRect(b.x + tx, b.y + ty, Math.min(16, b.w - tx), Math.min(16, b.h - ty)); } } + ctx.fillStyle = "rgba(255,255,255,0.28)"; + for (let ty = 0; ty < b.h; ty += 16) ctx.fillRect(b.x, b.y + ty, b.w, 1); + for (let tx = 0; tx < b.w; tx += 16) ctx.fillRect(b.x + tx, b.y, 1, b.h); +} + +/** Soft shadows on the floor where furniture and walls meet it, cast toward the south-east. */ +export function drawContactShadows(ctx: CanvasRenderingContext2D, frame: IsoFrame, items: SceneDrawable[]): void { + ctx.save(); + setGroundTransform(ctx, frame); + ctx.fillStyle = "rgba(28,18,10,0.2)"; + for (const item of items) if (item.grounded) ctx.fillRect(item.foot.x + 1, item.foot.y + 1, item.foot.w + 3, item.foot.h + 3); + ctx.restore(); } /** What the back walls show: the sky and weather outside, and the time for the wall clock. */ @@ -115,6 +145,10 @@ export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, pl ctx.fillRect(0, 0, w, 2); ctx.fillStyle = PALETTE.panelSeam; for (let x = 32; x < w; x += 32) ctx.fillRect(x, 2, 1, BACK_WALL_H - 5); + ctx.fillStyle = "#62574b"; + ctx.fillRect(0, BACK_WALL_H - 14, w, 11); + ctx.fillStyle = "#857766"; + ctx.fillRect(0, BACK_WALL_H - 15, w, 1); ctx.fillStyle = PALETTE.baseboard; ctx.fillRect(0, BACK_WALL_H - 3, w, 3); for (const room of plan.rooms) { @@ -126,6 +160,14 @@ export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, pl drawWeather(ctx, rect, outside.weather, outside.timeMs, seed); ctx.fillStyle = PALETTE.frame; ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h); + ctx.fillStyle = "#9c8f7c"; + ctx.fillRect(rect.x - 3, rect.y + rect.h + 2, rect.w + 6, 1); + ctx.fillStyle = "#b9a98f"; + ctx.fillRect(rect.x - 7, rect.y - 3, 5, rect.h + 6); + ctx.fillRect(rect.x + rect.w + 2, rect.y - 3, 5, rect.h + 6); + ctx.fillStyle = "#a39278"; + ctx.fillRect(rect.x - 5, rect.y - 3, 1, rect.h + 6); + ctx.fillRect(rect.x + rect.w + 4, rect.y - 3, 1, rect.h + 6); }); } drawWallArt(ctx); @@ -167,7 +209,7 @@ function drawWallArt(ctx: CanvasRenderingContext2D): void { /** A block sorted by its footprint centre; one resting on `support` touches exactly one piece of it (overhanging its outer edge is fine) and sorts just after that piece. */ export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): SceneDrawable { - return { foot, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) }; + return { foot, grounded: z === 0 && !support, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) }; } function restingDepth(foot: Rect, support: Rect): number { @@ -210,7 +252,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, block(counter, 16, faces("#8f8578")); block({ x: 700, y: 20, w: 24, h: 20 }, 22, faces("#d5dade")); const machine = { x: 726, y: 44, w: 14, h: 14 }; - items.push({ foot: machine, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) }); + items.push({ foot: machine, grounded: false, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) }); block({ x: 792, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter); block({ x: 826, y: 46, w: 12, h: 10 }, 10, faces("#b3543f"), 16, counter); block({ x: 748, y: 170, w: 40, h: 40 }, 12, faces("#94897b")); @@ -220,33 +262,61 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, for (let i = 0; i < 3; i++) { const cx = 1084 + i * 40; const toilet = { x: cx - 6, y: WALL + 2, w: 12, h: 16 }; - items.push({ foot: toilet, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) }); + items.push({ foot: toilet, grounded: true, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) }); } for (let i = 0; i <= 3; i++) block({ x: 1063 + i * 40, y: WALL, w: 2, h: 48 }, INNER_WALL_H, faces("#7d8a99")); const sinks = { x: 928, y: 32, w: 96, h: 32 }; block(sinks, 14, { top: "#c6d2de", south: "#a7b4c2", east: "#909eae" }); for (let i = 0; i < 2; i++) { const basin = { x: sinks.x + 2 + i * 64, y: sinks.y + 8, w: 28, h: 16 }; - items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) }); + items.push({ foot: basin, grounded: false, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) }); } for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy); for (const desk of plan.desks) deskBlocks(desk.bounds, false, screens.get(desk.bounds) ?? "idle", timeMs).forEach((item) => items.push(item(frame))); return items; } -/** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */ +/** A desk with its monitor, keyboard and clutter, and the office chair in front of it, each a separate block so a seated person sorts between them. */ function deskBlocks(desk: Rect, executive: boolean, screen: OfficeState, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> { const monitor = deskMonitor(desk); - return [ + const middle = desk.x + Math.floor(desk.w / 2 / PIECE) * PIECE; + const seed = Math.round(desk.x + desk.y); + const on = (foot: Rect, height: number, colors: BoxColors, z = 14) => (frame: IsoFrame) => blockDrawable(frame, foot, height, colors, z, desk); + const parts: Array<(frame: IsoFrame) => SceneDrawable> = [ ...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))), + on({ x: monitor.x + 11, y: monitor.y + 1, w: 4, h: 4 }, 2, faces("#3a3e45")), (frame) => { - const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk); - return { foot: monitor, depth: shell.depth, draw: (ctx) => { + const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 16, desk); + return { foot: monitor, grounded: false, depth: shell.depth, draw: (ctx) => { shell.draw(ctx); - drawMonitorScreen(ctx, frame, monitor, 14, 12, screen, timeMs, Math.round(desk.x + desk.y)); + drawMonitorScreen(ctx, frame, monitor, 16, 12, screen, timeMs, seed); } }; }, - (frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)), + on({ x: middle + 5, y: desk.y + 16, w: 20, h: 6 }, 1, { top: "#d9dce0", south: "#b8bcc2", east: "#a6abb2" }), + on({ x: middle + 27, y: desk.y + 18, w: 3, h: 4 }, 1, faces("#cfd3d8")), + ...chairBlocks(desk.x + desk.w / 2, desk.y + desk.h + 6), + ]; + if (seed % 2 === 0) parts.push(on({ x: desk.x + 10, y: desk.y + 10, w: 5, h: 5 }, 5, { top: "#6b4a2f", south: "#e8e3d8", east: "#cfc9bc" })); + if (seed % 3 === 0) parts.push(on({ x: desk.x + desk.w - 14, y: desk.y + 8, w: 10, h: 12 }, 2, faces("#f4f1ea"))); + if (seed % 3 === 1) { + parts.push(on({ x: desk.x + desk.w - 13, y: desk.y + 6, w: 8, h: 8 }, 4, faces("#c9693f"))); + parts.push(on({ x: desk.x + desk.w - 14, y: desk.y + 5, w: 10, h: 10 }, 6, faces("#4f8a45"), 18)); + } + if (executive) { + parts.push(on({ x: desk.x + 8, y: desk.y + 6, w: 6, h: 6 }, 1, faces("#b89448"))); + parts.push(on({ x: desk.x + 10, y: desk.y + 8, w: 2, h: 2 }, 10, faces("#b89448"), 15)); + parts.push(on({ x: desk.x + 7, y: desk.y + 5, w: 8, h: 8 }, 4, faces("#2f5d4a"), 25)); + } + return parts; +} + +/** An office chair: five-star base, gas lift, cushioned seat, and a backrest on the desk side that the sitter covers. */ +function chairBlocks(cx: number, y: number): Array<(frame: IsoFrame) => SceneDrawable> { + return [ + (frame) => blockDrawable(frame, { x: cx - 7, y: y + 1, w: 14, h: 10 }, 2, faces("#2d3036")), + (frame) => blockDrawable(frame, { x: cx - 1, y: y + 5, w: 2, h: 2 }, 5, faces("#5d6168"), 2), + (frame) => blockDrawable(frame, { x: cx - 8, y, w: 16, h: 11 }, 3, faces("#4a5060"), 7), + (frame) => blockDrawable(frame, { x: cx - 8, y, w: 16, h: 3 }, 11, faces(PALETTE.chair), 8), ]; } diff --git a/src/renderer/src/pages/office/sprites.ts b/src/renderer/src/pages/office/sprites.ts index f0bf0512..e7e8524d 100644 --- a/src/renderer/src/pages/office/sprites.ts +++ b/src/renderer/src/pages/office/sprites.ts @@ -1,9 +1,9 @@ /** * Deterministic pixel people. Every agent gets a stable appearance derived - * from identity (skin tone, hair style and color, outfit, glasses), drawn - * procedurally with facing-aware variants and a two-frame walk cycle, so no - * two colleagues read as clones and nobody ships binary sprite sheets. + * from identity; poses are drawn at twice the scene's pixel density by + * person-art.ts, finished with rim light and a selective outline, and cached. */ +import { ANCHOR, ART_H, ART_W, personArt, type PersonFrame } from "./person-art"; export type Facing = "north" | "south" | "east" | "west"; export type HairStyle = "buzz" | "short" | "bob" | "long" | "ponytail" | "curly" | "bald"; @@ -18,13 +18,15 @@ export interface SpriteAppearance { shirt: string; accent: string; glasses: boolean; + pants: string; + shoes: string; } export interface PersonPose { facing: Facing; /** Radians accumulated while walking; 0 means standing still. */ walkPhase: number; - /** Seated work bob (used at desks). */ + /** Typing at a desk. */ working: boolean; carrying: Carrying; timeMs: number; @@ -39,10 +41,8 @@ const SHIRT_COLORS = [ "#4fb8b8", "#d97b5b", "#7ba95a", "#c4566f", "#6a7dd9", ]; const ACCENT_COLORS = ["#e5c65a", "#d95b5b", "#5bd9c0", "#c9c9d4", "#8ad95b"]; -const BLAZER = "#37415c"; -const BLAZER_CEO = "#2c3448"; -const PANTS = "#3a3f47"; -const SHOES = "#26292e"; +const PANTS_COLORS = ["#3a3f47", "#2f4a6b", "#6b5a45", "#4a4a52", "#2b2d33"]; +const SHOE_COLORS = ["#26292e", "#5a3a26", "#e8e6e0", "#26292e"]; export function hashIdentity(value: string): number { let hash = 2166136261; @@ -67,10 +67,50 @@ export function appearanceFor(id: string, isCeo: boolean): SpriteAppearance { shirt: pick(SHIRT_COLORS, hash, 13), accent: pick(ACCENT_COLORS, hash, 17), glasses: (hash >>> 5) % 100 < 28, + pants: isCeo ? "#22252c" : pick(PANTS_COLORS, hash, 19), + shoes: isCeo ? "#1b1c20" : pick(SHOE_COLORS, hash, 23), }; } -/** Draws a person anchored at foot-center (x, y). Total height ~34px. */ +/** The pose frame a moment shows: four walk steps, two typing beats, a blink every few seconds. */ +export function frameFor(pose: PersonPose): PersonFrame { + const turn = 2 * Math.PI; + const step = pose.walkPhase === 0 ? -1 : Math.floor(((((pose.walkPhase % turn) + turn) % turn) / turn) * 4); + const blinkEvery = 3200 + (pose.animSeed % 7) * 300; + return { + facing: pose.facing, + step, + typing: pose.working ? Math.floor(pose.timeMs / 180 + pose.animSeed) % 2 : -1, + blink: (pose.timeMs + pose.animSeed * 97) % blinkEvery < 120, + carrying: pose.carrying, + }; +} + +/** Most recently drawn pose canvases; the least recent go first past the cap. */ +const cache = new Map(); +const CACHE_CAP = 2048; + +function spriteFor(appearance: SpriteAppearance, frame: PersonFrame, isCeo: boolean): HTMLCanvasElement { + const key = `${JSON.stringify(appearance)}|${isCeo}|${frame.facing}|${frame.step}|${frame.typing}|${frame.blink}|${frame.carrying}`; + const cached = cache.get(key); + if (cached) { + cache.delete(key); + cache.set(key, cached); + return cached; + } + const grid = personArt(appearance, frame, isCeo); + const canvas = document.createElement("canvas"); + canvas.width = ART_W; + canvas.height = ART_H; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("A sprite canvas has no 2D context."); + ctx.putImageData(new ImageData(grid.data, ART_W, ART_H), 0, 0); + cache.set(key, canvas); + if (cache.size > CACHE_CAP) cache.delete(cache.keys().next().value!); + return canvas; +} + +/** Draws a person standing at foot-centre (x, y) in scene pixels; the sprite is half its art size there. */ export function drawPerson( ctx: CanvasRenderingContext2D, x: number, @@ -79,194 +119,12 @@ export function drawPerson( pose: PersonPose, isCeo: boolean, ): void { - const px = Math.round(x); - const walking = pose.walkPhase !== 0; - const stride = walking ? Math.sin(pose.walkPhase) * 2.5 : 0; - const bob = pose.working - ? Math.sin(pose.timeMs * 0.003 + pose.animSeed) * 1.2 - : walking ? Math.abs(Math.sin(pose.walkPhase)) * -1 : 0; - const py = Math.round(y + bob); - - ctx.fillStyle = "rgba(24,18,12,0.28)"; - ctx.fillRect(px - 8, Math.round(y) + 1, 16, 4); - - drawLegs(ctx, px, py, stride); - drawTorso(ctx, px, py, appearance, pose, isCeo); - drawHead(ctx, px, py, appearance, pose.facing); - if (pose.carrying) drawCarriedItem(ctx, px, py, pose.carrying, pose.facing); -} - -function drawLegs(ctx: CanvasRenderingContext2D, x: number, y: number, stride: number): void { - const left = Math.round(stride); - const right = Math.round(-stride); - ctx.fillStyle = PANTS; - ctx.fillRect(x - 5, y - 8 + Math.max(0, -left), 4, 8 - Math.abs(Math.min(left, 0))); - ctx.fillRect(x + 1, y - 8 + Math.max(0, -right), 4, 8 - Math.abs(Math.min(right, 0))); - ctx.fillStyle = SHOES; - ctx.fillRect(x - 6, y - 2 + (left > 0 ? -left : 0), 5, 3); - ctx.fillRect(x + 1, y - 2 + (right > 0 ? -right : 0), 5, 3); -} - -function drawTorso( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - appearance: SpriteAppearance, - pose: PersonPose, - isCeo: boolean, -): void { - const top = y - 20; - if (appearance.outfit === "blazer") { - ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER; - ctx.fillRect(x - 8, top, 16, 12); - if (pose.facing !== "north") { - ctx.fillStyle = "#e8e6df"; - ctx.fillRect(x - 2, top, 4, 7); - ctx.fillStyle = appearance.accent; - ctx.fillRect(x - 1, top + 1, 2, 6); - } - ctx.fillStyle = isCeo ? BLAZER_CEO : BLAZER; - ctx.fillRect(x - 10, top + 1, 2, 9); - ctx.fillRect(x + 8, top + 1, 2, 9); - ctx.fillStyle = appearance.skin; - ctx.fillRect(x - 10, top + 10, 2, 2); - ctx.fillRect(x + 8, top + 10, 2, 2); - return; - } - if (appearance.outfit === "hoodie") { - ctx.fillStyle = appearance.shirt; - ctx.fillRect(x - 8, top, 16, 12); - ctx.fillRect(x - 10, top + 1, 2, 9); - ctx.fillRect(x + 8, top + 1, 2, 9); - ctx.fillStyle = shade(appearance.shirt); - ctx.fillRect(x - 8, top, 16, 3); - if (pose.facing !== "north") ctx.fillRect(x - 4, top + 7, 8, 4); - ctx.fillStyle = appearance.skin; - ctx.fillRect(x - 10, top + 10, 2, 2); - ctx.fillRect(x + 8, top + 10, 2, 2); - return; - } - ctx.fillStyle = appearance.shirt; - ctx.fillRect(x - 8, top, 16, 12); - ctx.fillRect(x - 10, top + 1, 2, 4); - ctx.fillRect(x + 8, top + 1, 2, 4); - ctx.fillStyle = appearance.skin; - ctx.fillRect(x - 10, top + 5, 2, 7); - ctx.fillRect(x + 8, top + 5, 2, 7); -} - -function drawHead(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void { - const top = y - 33; - ctx.fillStyle = appearance.skin; - ctx.fillRect(x - 7, top, 14, 13); - - if (facing === "north") { - if (appearance.hairStyle !== "bald") { - ctx.fillStyle = appearance.hair; - ctx.fillRect(x - 7, top, 14, 9); - } - drawHairEdges(ctx, x, y, appearance, facing); - return; - } - - drawHairEdges(ctx, x, y, appearance, facing); - ctx.fillStyle = "#23232a"; - if (facing === "south") { - ctx.fillRect(x - 4, top + 6, 2, 2); - ctx.fillRect(x + 2, top + 6, 2, 2); - } else if (facing === "east") { - ctx.fillRect(x + 3, top + 6, 2, 2); - } else { - ctx.fillRect(x - 5, top + 6, 2, 2); - } - if (appearance.glasses) drawGlasses(ctx, x, top, facing); -} - -function drawHairEdges(ctx: CanvasRenderingContext2D, x: number, y: number, appearance: SpriteAppearance, facing: Facing): void { - const top = y - 33; - if (appearance.hairStyle === "bald") return; - ctx.fillStyle = appearance.hair; - switch (appearance.hairStyle) { - case "buzz": - ctx.fillRect(x - 7, top, 14, 3); - break; - case "short": - ctx.fillRect(x - 7, top, 14, 4); - ctx.fillRect(x - 7, top + 4, 2, 3); - ctx.fillRect(x + 5, top + 4, 2, 3); - break; - case "bob": - ctx.fillRect(x - 7, top, 14, 4); - ctx.fillRect(x - 8, top + 2, 2, 9); - ctx.fillRect(x + 6, top + 2, 2, 9); - break; - case "long": - ctx.fillRect(x - 7, top, 14, 4); - ctx.fillRect(x - 9, top + 2, 3, 15); - ctx.fillRect(x + 6, top + 2, 3, 15); - break; - case "ponytail": - ctx.fillRect(x - 7, top, 14, 4); - if (facing === "north") ctx.fillRect(x - 2, top + 4, 4, 10); - else if (facing === "east") ctx.fillRect(x - 9, top + 3, 3, 8); - else if (facing === "west") ctx.fillRect(x + 6, top + 3, 3, 8); - else ctx.fillRect(x + 6, top + 1, 2, 5); - break; - case "curly": - ctx.fillRect(x - 7, top, 14, 4); - for (let i = 0; i < 7; i++) ctx.fillRect(x - 7 + i * 2, top + 3 + (i % 2), 2, 2); - break; - } -} - -function drawGlasses(ctx: CanvasRenderingContext2D, x: number, headTop: number, facing: Facing): void { - ctx.fillStyle = "#1c1c22"; - if (facing === "south") { - ctx.fillRect(x - 5, headTop + 5, 4, 1); - ctx.fillRect(x + 1, headTop + 5, 4, 1); - ctx.fillRect(x - 5, headTop + 8, 4, 1); - ctx.fillRect(x + 1, headTop + 8, 4, 1); - ctx.fillRect(x - 5, headTop + 5, 1, 4); - ctx.fillRect(x - 2, headTop + 5, 1, 4); - ctx.fillRect(x + 1, headTop + 5, 1, 4); - ctx.fillRect(x + 4, headTop + 5, 1, 4); - ctx.fillRect(x - 1, headTop + 6, 2, 1); - } else if (facing === "east") { - ctx.fillRect(x + 2, headTop + 5, 4, 1); - ctx.fillRect(x + 2, headTop + 8, 4, 1); - ctx.fillRect(x + 2, headTop + 5, 1, 4); - ctx.fillRect(x + 5, headTop + 5, 1, 4); - } else if (facing === "west") { - ctx.fillRect(x - 6, headTop + 5, 4, 1); - ctx.fillRect(x - 6, headTop + 8, 4, 1); - ctx.fillRect(x - 6, headTop + 5, 1, 4); - ctx.fillRect(x - 3, headTop + 5, 1, 4); - } -} - -function drawCarriedItem(ctx: CanvasRenderingContext2D, x: number, y: number, item: Carrying, facing: Facing): void { - const side = facing === "west" ? -12 : 10; - const ix = x + side; - const iy = y - 14; - if (item === "coffee") { - ctx.fillStyle = "#f0ece2"; - ctx.fillRect(ix, iy, 4, 5); - ctx.fillStyle = "#6b4a2f"; - ctx.fillRect(ix, iy, 4, 1); - ctx.fillStyle = "rgba(240,240,245,0.7)"; - ctx.fillRect(ix + 1, iy - 3, 1, 2); - } else { - ctx.fillStyle = "#c9553e"; - ctx.fillRect(ix, iy, 5, 4); - ctx.fillStyle = "#e8b84a"; - ctx.fillRect(ix + 1, iy + 1, 3, 2); - } -} - -function shade(hex: string): string { - const value = parseInt(hex.slice(1), 16); - const r = Math.max(0, (value >> 16) - 36); - const g = Math.max(0, ((value >> 8) & 0xff) - 36); - const b = Math.max(0, (value & 0xff) - 36); - return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`; + const frame = frameFor(pose); + const breath = frame.step < 0 && frame.typing < 0 && Math.floor(pose.timeMs / 1100 + pose.animSeed) % 3 === 0 ? -0.5 : 0; + const ax = Math.round(x * 2) / 2; + const ay = Math.round(y * 2) / 2; + ctx.fillStyle = "rgba(24,18,12,0.3)"; + ctx.fillRect(ax - 6, ay - 0.5, 12, 1.5); + ctx.fillRect(ax - 4, ay + 1, 8, 1); + ctx.drawImage(spriteFor(appearance, frame, isCeo), ax - ANCHOR.x / 2, ay + breath - ANCHOR.y / 2, ART_W / 2, ART_H / 2); } diff --git a/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx b/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx index 3e37d6c0..851736ae 100644 --- a/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx +++ b/src/renderer/src/pages/settings/DisconnectProviderDialog.tsx @@ -5,11 +5,10 @@ import type { SnapshotActions } from "../../app/useSnapshot"; import { Dialog } from "../../shared/Dialog"; import { t } from "../../i18n/i18n"; -/** Shows what a disconnect stops before it happens, then its outcome, including a provider that refused to revoke. */ -export function DisconnectProviderDialog({ orgId, provider, revokesRemotely, actions, onClose }: { +/** Shows what a disconnect stops before it happens, then its outcome. */ +export function DisconnectProviderDialog({ orgId, provider, actions, onClose }: { orgId: string; provider: ProviderRecord; - revokesRemotely: boolean; actions: SnapshotActions; onClose: () => void; }) { @@ -55,9 +54,7 @@ export function DisconnectProviderDialog({ orgId, provider, revokesRemotely, act {impact.agents.length > 0 &&
  • {t("Open tasks assigned to them: {count}.", { count: impact.openTasks })}
  • }
  • {t("Running sessions that stop now: {count}.", { count: impact.runningRuns })}
  • {t("Queued sessions that will fail when they start: {count}.", { count: impact.queuedRuns })}
  • -
  • {revokesRemotely - ? t("Revokes the sign-in at {provider} and erases it from this computer.", { provider: provider.label }) - : t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
  • +
  • {t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
  • )} {state.kind === "error" &&
    {state.message}
    } diff --git a/src/renderer/src/pages/settings/IntegrationKeysPanel.tsx b/src/renderer/src/pages/settings/IntegrationKeysPanel.tsx index b842fa32..fc596944 100644 --- a/src/renderer/src/pages/settings/IntegrationKeysPanel.tsx +++ b/src/renderer/src/pages/settings/IntegrationKeysPanel.tsx @@ -16,11 +16,6 @@ const KEY_COPY: Record {t("Keys are stored on this device, encrypted by the operating system, and shared by every company.")}

    - {keys.map((status) => )} + {keys.map((status) => )}
    ); } -function KeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) { +export function IntegrationKeyRow({ status, actions }: { status: IntegrationKeyStatus; actions: SnapshotActions }) { const copy = KEY_COPY[status.name]; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index 53e7c3df..8ed6eb61 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -93,7 +93,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth onManage={() => setActive(provider)} onChangeModel={provider.providerKey === "azure-openai" ? null : () => openModel(provider)} onRevoke={() => setDisconnecting(provider)} - revokesRemotely={requireCatalogEntry(catalog, provider.providerKey).revokesRemotely} /> ))}
    @@ -151,7 +150,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth key={disconnecting.id} orgId={orgId} provider={disconnecting} - revokesRemotely={requireCatalogEntry(catalog, disconnecting.providerKey).revokesRemotely} actions={actions} onClose={() => setDisconnecting(null)} /> @@ -235,7 +233,6 @@ function ProviderRow({ onManage, onChangeModel, onRevoke, - revokesRemotely, }: { provider: ProviderRecord; authProfile: ProviderAuthProfileRecord | null; @@ -245,7 +242,6 @@ function ProviderRow({ /** Null where the model lives in the credential (Azure's deployment). */ onChangeModel: (() => void) | null; onRevoke: () => void; - revokesRemotely: boolean; }) { const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length; return ( @@ -279,9 +275,7 @@ function ProviderRow({ diff --git a/src/renderer/src/pages/team/AgentModelField.tsx b/src/renderer/src/pages/team/AgentModelField.tsx new file mode 100644 index 00000000..bc0d1931 --- /dev/null +++ b/src/renderer/src/pages/team/AgentModelField.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react"; +import type { ProviderCatalogEntry, ProviderRecord } from "../../../../shared/domain"; +import type { ProviderModelEntry } from "../../../../shared/provider-models"; +import { Field } from "../../shared/ui"; +import { t } from "../../i18n/i18n"; + +type Fetched = { providerId: string; models: ProviderModelEntry[] } | { providerId: string; error: string }; + +/** An agent's model: the provider's whole list (live from a connected provider, else its catalog), blank meaning the provider's default. */ +export function AgentModelField({ provider, entry, value, onChange }: { + provider: ProviderRecord | null; + entry: ProviderCatalogEntry | null; + value: string; + onChange: (model: string) => void; +}) { + const live = Boolean(provider && entry?.supportsLiveModels && provider.credentialId && provider.enabled && provider.authState === "configured"); + const [fetched, setFetched] = useState(null); + const providerId = provider?.id ?? null; + const providerKey = provider?.providerKey ?? null; + const orgId = provider?.orgId ?? null; + + useEffect(() => { + if (!live || !providerId || !providerKey || !orgId) return; + let current = true; + void window.agentCompany.listProviderModels({ providerKey, orgId, providerId }).then((result) => { + if (!current) return; + setFetched(result.ok ? { providerId, models: result.data } : { providerId, error: result.error.message }); + }); + return () => { + current = false; + }; + }, [live, providerId, providerKey, orgId]); + + const mine = fetched && fetched.providerId === providerId ? fetched : null; + const models = mine && "models" in mine ? mine.models : entry?.models ?? []; + const known = models.some((model) => model.id === value); + + return ( + <> + + {!provider || models.length === 0 ? ( + onChange(event.currentTarget.value)} /> + ) : ( + + )} + + {live && !mine &&
    {t("Loading models...")}
    } + {mine && "error" in mine &&
    {mine.error}
    } + + ); +} diff --git a/src/renderer/src/shared/ProviderLogo.tsx b/src/renderer/src/shared/ProviderLogo.tsx index 1cf7bba9..0ab7a7ab 100644 --- a/src/renderer/src/shared/ProviderLogo.tsx +++ b/src/renderer/src/shared/ProviderLogo.tsx @@ -9,7 +9,6 @@ import openrouterLogo from "../assets/provider-logos/openrouter.png"; import kimiLogo from "../assets/provider-logos/kimi.png"; import kimiCnLogo from "../assets/provider-logos/kimi-cn.png"; import githubCopilotLogo from "../assets/provider-logos/github-copilot.png"; -import googleAntigravityLogo from "../assets/provider-logos/google-antigravity.png"; import gptAccountLogo from "../assets/provider-logos/gpt-account.png"; import azureOpenAiLogo from "../assets/provider-logos/azure-openai.png"; import awsBedrockLogo from "../assets/provider-logos/aws-bedrock.png"; @@ -35,7 +34,6 @@ const PROVIDER_LOGOS: Record = { "kimi-cn": kimiCnLogo, "kimi-code": kimiLogo, "github-copilot": githubCopilotLogo, - "google-antigravity": googleAntigravityLogo, "gpt-account": gptAccountLogo, "azure-openai": azureOpenAiLogo, "aws-bedrock": awsBedrockLogo, diff --git a/src/renderer/src/shared/Shell.tsx b/src/renderer/src/shared/Shell.tsx index 9b47a587..e98e320a 100644 --- a/src/renderer/src/shared/Shell.tsx +++ b/src/renderer/src/shared/Shell.tsx @@ -1,5 +1,5 @@ import type { Focus } from "../pages/types"; -import type { RouteId } from "./routes"; +import { connectorsEnabled, type RouteId } from "./routes"; import { shortcutLabel } from "../i18n/labels"; import { isBusinessTask } from "../pages/work/task-helpers"; import { unreadBySpace } from "../pages/chat/chat-helpers"; @@ -98,7 +98,7 @@ export function Shell({ {shortcutLabel("K")} diff --git a/src/renderer/src/shared/routes.ts b/src/renderer/src/shared/routes.ts index b159fd0c..4bf17582 100644 --- a/src/renderer/src/shared/routes.ts +++ b/src/renderer/src/shared/routes.ts @@ -1,2 +1,9 @@ +import type { AppSnapshot } from "../../../shared/domain"; + export const ROUTE_IDS = ["home", "team", "work", "chat", "files", "library", "runs", "approvals", "connectors", "office", "settings"] as const; export type RouteId = (typeof ROUTE_IDS)[number]; + +/** Connectors exist once a Composio key is saved. */ +export function connectorsEnabled(snapshot: AppSnapshot): boolean { + return snapshot.integrationKeys.some((key) => key.name === "composio" && key.configured); +} diff --git a/src/shared/commands/integration-keys.ts b/src/shared/commands/integration-keys.ts index d143d001..058f07ee 100644 --- a/src/shared/commands/integration-keys.ts +++ b/src/shared/commands/integration-keys.ts @@ -1,4 +1,4 @@ -export const INTEGRATION_KEY_NAMES = ["composio", "logo_dev", "google_antigravity_client_secret"] as const; +export const INTEGRATION_KEY_NAMES = ["composio", "logo_dev"] as const; export type IntegrationKeyName = (typeof INTEGRATION_KEY_NAMES)[number]; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index b01447e4..e236e1d0 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -359,8 +359,6 @@ export interface ProviderCatalogEntry { /** Set for user-configured OpenAI/Anthropic-compatible endpoints: the UI * collects a base URL + API key + model instead of a fixed provider form. */ customEndpoint?: "openai" | "anthropic"; - /** True when disconnecting also revokes the sign-in at the provider; otherwise it only erases it from this computer. */ - revokesRemotely: boolean; } export interface SkillDefinitionRecord { diff --git a/src/shared/provider-catalog.ts b/src/shared/provider-catalog.ts index 3de4549f..ea2ea541 100644 --- a/src/shared/provider-catalog.ts +++ b/src/shared/provider-catalog.ts @@ -1,12 +1,12 @@ import { PROVIDER_AUTH_TYPE, type ProviderCatalogEntry } from "./domain"; import { listProviderModels } from "./provider-models"; -const RAW_CATALOG: Omit[] = [ +const RAW_CATALOG: Omit[] = [ { key: "openai", label: "OpenAI", authType: PROVIDER_AUTH_TYPE.API_KEY, - defaultModel: "gpt-5.4", + defaultModel: "gpt-6.1-sol", capabilityTags: ["responses", "tools", "background"], signInUrl: null, credentialHelp: "Generate an API key at platform.openai.com → API keys.", @@ -101,20 +101,12 @@ const RAW_CATALOG: Omit ({ ...entry, models: listProviderModels(entry.key), supportsLiveModels: !CURATED_MODEL_PROVIDERS.has(entry.key), - revokesRemotely: REMOTE_REVOCATION.has(entry.key), })); diff --git a/src/shared/provider-models.ts b/src/shared/provider-models.ts index 810db793..fe5fed13 100644 --- a/src/shared/provider-models.ts +++ b/src/shared/provider-models.ts @@ -33,44 +33,18 @@ const ANTHROPIC_MODELS: ProviderModelEntry[] = [ export const PROVIDER_MODELS: Record = { openai: [ + { id: "gpt-6-astra", name: "GPT-6 Astra" }, + { id: "gpt-6.1-sol", name: "GPT-6.1 Sol" }, + { id: "gpt-6-sol", name: "GPT-6 Sol" }, + { id: "gpt-6-luna", name: "GPT-6 Luna" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-5.6-terra", name: "GPT-5.6 Terra" }, + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna" }, + { id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, { id: "gpt-5.5", name: "GPT-5.5" }, { id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, { id: "gpt-5.4", name: "GPT-5.4" }, { id: "gpt-5.4-mini", name: "GPT-5.4 mini" }, - { id: "gpt-5.4-nano", name: "GPT-5.4 nano" }, - { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { id: "gpt-5.3-chat-latest", name: "GPT-5.3 Chat" }, - { id: "gpt-5.2-pro", name: "GPT-5.2 Pro" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { id: "gpt-5.2-chat-latest", name: "GPT-5.2 Chat" }, - { id: "gpt-5.1", name: "GPT-5.1" }, - { id: "gpt-5.1-codex", name: "GPT-5.1 Codex" }, - { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, - { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex mini" }, - { id: "gpt-5.1-chat-latest", name: "GPT-5.1 Chat" }, - { id: "gpt-5-pro", name: "GPT-5 Pro" }, - { id: "gpt-5", name: "GPT-5" }, - { id: "gpt-5-codex", name: "GPT-5 Codex" }, - { id: "gpt-5-mini", name: "GPT-5 mini" }, - { id: "gpt-5-nano", name: "GPT-5 nano" }, - { id: "gpt-5-chat-latest", name: "GPT-5 Chat" }, - { id: "o4-mini", name: "o4-mini" }, - { id: "o4-mini-deep-research", name: "o4-mini deep research" }, - { id: "o3", name: "o3" }, - { id: "o3-pro", name: "o3-pro" }, - { id: "o3-mini", name: "o3-mini" }, - { id: "o3-deep-research", name: "o3 deep research" }, - { id: "o1", name: "o1" }, - { id: "o1-pro", name: "o1-pro" }, - { id: "gpt-4.1", name: "GPT-4.1" }, - { id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, - { id: "gpt-4.1-nano", name: "GPT-4.1 nano" }, - { id: "gpt-4o", name: "GPT-4o" }, - { id: "gpt-4o-mini", name: "GPT-4o mini" }, - { id: "gpt-4-turbo", name: "GPT-4 Turbo" }, - { id: "gpt-4", name: "GPT-4" }, ], anthropic: ANTHROPIC_MODELS, "claude-pro": ANTHROPIC_MODELS, @@ -138,28 +112,15 @@ export const PROVIDER_MODELS: Record = { { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "grok-code-fast-1", name: "Grok Code Fast 1" }, ], - "google-antigravity": [ - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Antigravity)" }, - { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Antigravity)" }, - { id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" }, - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, - { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking" }, - { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "gpt-oss-120b-medium", name: "GPT-OSS 120B Medium" }, - ], "gpt-account": [ + { id: "gpt-6.1-sol", name: "GPT-6.1 Sol" }, + { id: "gpt-6-astra", name: "GPT-6 Astra" }, + { id: "gpt-6-sol", name: "GPT-6 Sol" }, + { id: "gpt-6-luna", name: "GPT-6 Luna" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { id: "gpt-5.6-terra", name: "GPT-5.6 Terra" }, + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna" }, { id: "gpt-5.5", name: "GPT-5.5" }, - { id: "gpt-5.4", name: "GPT-5.4" }, - { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, - { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { id: "gpt-5.1", name: "GPT-5.1" }, - { id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" }, - { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" }, ], "azure-openai": [ { id: "gpt-5.5", name: "GPT-5.5" }, diff --git a/tests/contract/provider-contracts.test.ts b/tests/contract/provider-contracts.test.ts index 62110edd..0a83b05c 100644 --- a/tests/contract/provider-contracts.test.ts +++ b/tests/contract/provider-contracts.test.ts @@ -16,7 +16,6 @@ const PROVIDER_SECRET_SAMPLES: Record = { "kimi-cn": "sk-live-provider", "kimi-code": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }), "github-copilot": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }), - "google-antigravity": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, projectId: "acme-project" }), "gpt-account": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, accountId: "acct_1" }), "azure-openai": JSON.stringify({ baseUrl: "https://acme.openai.azure.com", @@ -88,7 +87,7 @@ describe("provider contracts", () => { // Providers with no enumerable models endpoint keep returning the curated // catalog; the rest fetch live (covered offline by provider-models-fetch.test). const CURATED_MODEL_PROVIDERS = new Set([ - "github-copilot", "google-antigravity", "gpt-account", "azure-openai", "aws-bedrock", "vertex-ai", + "github-copilot", "gpt-account", "azure-openai", "aws-bedrock", "vertex-ai", "custom-openai", "custom-anthropic", ]); @@ -115,8 +114,6 @@ describe("provider contracts", () => { const secret = PROVIDER_SECRET_SAMPLES[provider.key]; const profile = adapter.parseAuthProfile(secret); await expect(adapter.authorize({ secret })).resolves.toEqual({ authProfile: profile }); - // Remote revocation makes a network call; provider-disconnect.test covers it. - if (!provider.revokesRemotely) await expect(adapter.revoke({ authProfile: profile, secret })).resolves.toBeUndefined(); await expect(adapter.refresh({ authProfile: profile })).rejects.toThrow("does not support token refresh"); } }); diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index dead5f5e..1b9afb65 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -43,7 +43,7 @@ test("the pinned CEO conversation is the Messages front door", async () => { await page.getByLabel("Acceptance criteria").fill("A working product, not documents about one."); await page.getByRole("button", { name: "Launch" }).click(); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await page.getByRole("button", { name: "Finish" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); // The CEO conversation is the first row, badged, and already selected — // opening Messages IS opening the CEO chat. diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 3faaef85..1a1cebc7 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -56,7 +56,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => { // The company exists after Launch; the wizard closes with the optional // connectors step. await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await page.getByRole("button", { name: "Finish" }).click(); + await expect(page.getByText("logo.dev publishable key")).toBeVisible(); + await page.getByRole("button", { name: "Skip" }).click(); + await expect(page.getByRole("button", { name: "Connectors", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: /Acme Operations/ })).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); @@ -74,6 +76,12 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByLabel("Role").fill("CTO"); await page.getByLabel("Department").fill("Engineering"); await page.getByLabel("Monthly budget").fill("250"); + // The model picker offers the provider's whole list, whatever is picked now. + const model = page.locator(".modal select[name=modelId]"); + await model.selectOption("gpt-6.1-sol"); + const offered = await model.locator("option").evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)); + expect(offered).toEqual(expect.arrayContaining(["", "gpt-6.1-sol", "gpt-6-astra", "gpt-5.4-mini"])); + await model.selectOption(""); await page.locator(".modal").getByRole("button", { name: "Hire" }).click(); await expect(page.getByText("Alex Chen").first()).toBeVisible(); const hiredAgent = await page.evaluate(async () => { @@ -108,20 +116,19 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Edit Alex Chen" }).click(); await page.locator(".modal").getByLabel("Name", { exact: true }).fill("Alex Turing"); await page.getByLabel("Avatar emoji").fill("🚀"); - // The blank model field names the default of whichever provider is picked now. - const editModel = page.locator(".modal").getByLabel("Model", { exact: true }); - await expect(editModel).toHaveAttribute("placeholder", "gpt-5.4"); + // The blank model choice names the default of whichever provider is picked now. + const editModel = page.locator(".modal [name=modelId]"); + await expect(editModel.locator("option").first()).toHaveText("gpt-5.4 (provider default)"); const editProvider = page.locator(".modal select[name=modelProviderId]"); const savedProvider = await editProvider.inputValue(); - // Suggestions come from the picked provider only, and switching provider clears the model. - await expect(page.locator("#edit-model-suggestions option").first()).toHaveAttribute("value", /.+/); - await expect(page.locator("#edit-model-suggestions option[value^=claude]")).toHaveCount(0); - await editModel.fill("gpt-5.4"); + // Choices come from the picked provider only, and switching provider clears the model. + await expect(editModel.locator("option[value^=claude]")).toHaveCount(0); + await editModel.selectOption("gpt-5.4"); await editProvider.selectOption(""); - await expect(editModel).toHaveAttribute("placeholder", ""); await expect(editModel).toHaveValue(""); await expect(editModel).toBeDisabled(); await editProvider.selectOption(savedProvider); + await expect(editModel).toHaveValue(""); await page.locator(".modal").getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Alex Turing").first()).toBeVisible(); await expect(page.locator(".table-row", { hasText: "Alex Turing" }).locator(".avatar", { hasText: "🚀" })).toBeVisible(); @@ -213,12 +220,15 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Approvals" }).click(); await expect(page.getByText("Escalate release").first()).toBeVisible(); - // The Connectors page shell renders whether or not the Composio catalog - // is reachable from the test machine. + // A saved Composio key brings Connectors into the sidebar; the page renders whether or not Composio answers. + await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "Integrations", exact: true }).click(); + await page.locator("form").filter({ hasText: "Composio API key" }).getByLabel("Key").fill("ak_e2e"); + await page.locator("form").filter({ hasText: "Composio API key" }).getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Connectors", exact: true }).click(); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); - // Without a Composio key the account check fails, and the page says the statuses may be stale. + // A key Composio rejects fails the account check, and the page says the statuses may be stale. await expect(page.getByRole("alert").filter({ hasText: "Account status could not be checked with Composio" })).toBeVisible(); // A connector failure is shown whole and can be copied. const catalogError = page.getByRole("alert").filter({ has: page.getByRole("button", { name: "Retry" }) }).filter({ hasNotText: "Account status" }).first(); @@ -229,6 +239,7 @@ test("onboards, hires, and creates work in the Electron shell", async () => { expect((await app.evaluate(({ clipboard }) => clipboard.readText())).trim()).toBe(shownReason); await page.getByRole("button", { name: "Settings" }).click(); + await page.getByRole("button", { name: "General", exact: true }).click(); await expect(page.getByText("AI CEO", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Providers" }).click(); for (const label of ["Kimi", "Kimi CN", "Kimi Code"]) { diff --git a/tests/e2e/library.spec.ts b/tests/e2e/library.spec.ts index 5a5b37ab..4ca1c255 100644 --- a/tests/e2e/library.spec.ts +++ b/tests/e2e/library.spec.ts @@ -32,7 +32,7 @@ test("the library manages documents with versions and knowledge with history", a await page.getByLabel("Goal").fill("Keep company knowledge."); await page.getByLabel("Acceptance criteria").fill("Documents and knowledge are manageable."); await page.getByRole("button", { name: "Launch" }).click(); - await page.getByRole("button", { name: "Finish" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("button", { name: /Stack Works/ })).toBeVisible(); const orgId = await page.evaluate(async () => { diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index 677eaf53..d0e7455d 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -43,7 +43,7 @@ test("the office floor opens agent details on click", async () => { await page.getByLabel("Acceptance criteria").fill("Clicking an agent opens their card."); await page.getByRole("button", { name: "Launch" }).click(); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - await page.getByRole("button", { name: "Finish" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("button", { name: /Pixel Works/ })).toBeVisible(); await page.getByRole("button", { name: "Office", exact: true }).click(); diff --git a/tests/e2e/onboard.ts b/tests/e2e/onboard.ts index 3e5f469a..2322ed2b 100644 --- a/tests/e2e/onboard.ts +++ b/tests/e2e/onboard.ts @@ -14,7 +14,7 @@ export async function onboard(page: Page, company: string): Promise { await page.getByLabel("Goal").fill("Ship."); await page.getByLabel("Acceptance criteria").fill("It ships."); await page.getByRole("button", { name: "Launch" }).click(); - await page.getByRole("button", { name: "Finish" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); } diff --git a/tests/e2e/onboarding-resume.spec.ts b/tests/e2e/onboarding-resume.spec.ts index 826a27cd..2d910496 100644 --- a/tests/e2e/onboarding-resume.spec.ts +++ b/tests/e2e/onboarding-resume.spec.ts @@ -57,9 +57,9 @@ test("onboarding resumes where it was left after the app is closed", async () => app = await launch(); page = await app.firstWindow(); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); - // The step says when connector statuses could not be checked, as the Connectors page does. - await expect(page.getByRole("alert").filter({ hasText: "Account status could not be checked with Composio" })).toBeVisible(); - await page.getByRole("button", { name: "Finish" }).click(); + // Without a Composio key the step asks for one; skipping keeps Connectors out of the app. + await expect(page.getByText("Composio API key")).toBeVisible(); + await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); // A cancelled second company leaves nothing behind for the next attempt. diff --git a/tests/e2e/pixel-pages.spec.ts b/tests/e2e/pixel-pages.spec.ts index a6bee082..26c639d2 100644 --- a/tests/e2e/pixel-pages.spec.ts +++ b/tests/e2e/pixel-pages.spec.ts @@ -20,6 +20,7 @@ test("every page and onboarding step draws its text on the pixel font's grid", a await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); expect(await offGridText(page)).toEqual([]); await onboard(page, "Grid Co"); + await page.evaluate(() => window.agentCompany.setIntegrationKey({ name: "composio", value: "ak_e2e" })); for (const route of ["Home", "Work", "Team", "Office", "Messages", "Runs", "Files", "Library", "Approvals", "Connectors", "Settings"]) { await page.getByRole("button", { name: route, exact: true }).click(); await page.waitForTimeout(400); diff --git a/tests/e2e/screenshots.spec.ts b/tests/e2e/screenshots.spec.ts index 57e7e4fc..433d3377 100644 --- a/tests/e2e/screenshots.spec.ts +++ b/tests/e2e/screenshots.spec.ts @@ -46,7 +46,7 @@ test("captures every page for visual review", async () => { await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); await page.screenshot({ path: join(outDir, "05-onboarding-connectors.png"), fullPage: false }); - await page.getByRole("button", { name: "Finish" }).click(); + await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("button", { name: /OrbitOps/ })).toBeVisible(); await snap(page, outDir, "10-home"); @@ -56,6 +56,7 @@ test("captures every page for visual review", async () => { await go(page, "Messages", outDir, "13-messages"); await go(page, "Files", outDir, "14-files"); await go(page, "Approvals", outDir, "15-approvals"); + await page.evaluate(() => window.agentCompany.setIntegrationKey({ name: "composio", value: "ak_e2e" })); await go(page, "Connectors", outDir, "16-connectors"); await go(page, "Settings", outDir, "17-settings"); await go(page, "Office", outDir, "18-office"); diff --git a/tests/integration/chat-context.test.ts b/tests/integration/chat-context.test.ts index 63fe89c3..b6c0b73e 100644 --- a/tests/integration/chat-context.test.ts +++ b/tests/integration/chat-context.test.ts @@ -208,6 +208,21 @@ describe("chat wake context", () => { h.connection.close(); }); + test("a message stamped in the same millisecond as the reply still counts as new", async () => { + const h = await founderLine(); + const threadId = h.say("Ship on Monday.", null); + await h.runWake(); + h.say("Tie breaker.", threadId); + h.connection.db.exec(` + update chat_messages set created_at = (select created_at from chat_messages where sender_type = 'agent' order by rowid desc limit 1) + where body_md = 'Tie breaker.' + `); + await h.runWake(); + const prompt = h.wakePrompts[1]; + expect(prompt.slice(prompt.indexOf("New since your last reply"))).toContain(": Tie breaker."); + h.connection.close(); + }); + test("a long thread keeps every new message and the most recent history", async () => { const h = await founderLine(); const threadId = h.say("Message 0", null); diff --git a/tests/unit/agent-direct-messages.test.ts b/tests/unit/agent-direct-messages.test.ts index 1a458ef8..51727dee 100644 --- a/tests/unit/agent-direct-messages.test.ts +++ b/tests/unit/agent-direct-messages.test.ts @@ -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", () => { diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 79ea333b..24d57dae 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -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): 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 () => { diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index cb97749d..6a3cc935 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -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(); }); diff --git a/tests/unit/drop-google-antigravity-migration.test.ts b/tests/unit/drop-google-antigravity-migration.test.ts new file mode 100644 index 00000000..44c9edb2 --- /dev/null +++ b/tests/unit/drop-google-antigravity-migration.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/incomplete-turns.test.ts b/tests/unit/incomplete-turns.test.ts index 320b08c0..af436fd9 100644 --- a/tests/unit/incomplete-turns.test.ts +++ b/tests/unit/incomplete-turns.test.ts @@ -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 { @@ -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 () => { diff --git a/tests/unit/integration-key-store.test.ts b/tests/unit/integration-key-store.test.ts index 8e8a6ae5..9d03ef86 100644 --- a/tests/unit/integration-key-store.test.ts +++ b/tests/unit/integration-key-store.test.ts @@ -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(); diff --git a/tests/unit/office-art.test.ts b/tests/unit/office-art.test.ts new file mode 100644 index 00000000..14234d7d --- /dev/null +++ b/tests/unit/office-art.test.ts @@ -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); + }); +}); diff --git a/tests/unit/provider-disconnect.test.ts b/tests/unit/provider-disconnect.test.ts index 9782228b..4cd200db 100644 --- a/tests/unit/provider-disconnect.test.ts +++ b/tests/unit/provider-disconnect.test.ts @@ -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((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((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((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); diff --git a/tests/unit/provider-error-codes.test.ts b/tests/unit/provider-error-codes.test.ts index b09158bd..11220a13 100644 --- a/tests/unit/provider-error-codes.test.ts +++ b/tests/unit/provider-error-codes.test.ts @@ -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 () => { diff --git a/tests/unit/provider-oauth.test.ts b/tests/unit/provider-oauth.test.ts index 24298da7..47a85f14 100644 --- a/tests/unit/provider-oauth.test.ts +++ b/tests/unit/provider-oauth.test.ts @@ -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"); }); }); diff --git a/tests/unit/provider-tool-turns.test.ts b/tests/unit/provider-tool-turns.test.ts index 92b2590b..bf78237b 100644 --- a/tests/unit/provider-tool-turns.test.ts +++ b/tests/unit/provider-tool-turns.test.ts @@ -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 }>; - 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" }], diff --git a/tests/unit/reasoning-replay.test.ts b/tests/unit/reasoning-replay.test.ts index 6f3def4f..24a40e4f 100644 --- a/tests/unit/reasoning-replay.test.ts +++ b/tests/unit/reasoning-replay.test.ts @@ -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 { 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 () => { diff --git a/tests/unit/run-message-poller.test.ts b/tests/unit/run-message-poller.test.ts new file mode 100644 index 00000000..4e379031 --- /dev/null +++ b/tests/unit/run-message-poller.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/turn-request-bounds.test.ts b/tests/unit/turn-request-bounds.test.ts index 8ba52214..e1aec13a 100644 --- a/tests/unit/turn-request-bounds.test.ts +++ b/tests/unit/turn-request-bounds.test.ts @@ -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]); } diff --git a/tests/unit/ui-preferences.test.ts b/tests/unit/ui-preferences.test.ts new file mode 100644 index 00000000..8dcaff97 --- /dev/null +++ b/tests/unit/ui-preferences.test.ts @@ -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(); + }); +});