Compare commits

...
4 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 e374bc1e8e Release: drop an empty CSC_LINK so the keychain identity signs the macOS build
Build Release Installers / Release for the tag (push) Successful in 11s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m19s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m16s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m25s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m22s
electron-builder read the empty secret as a certificate path and failed with "not a file". Also keeps lighting.ts types module-private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:31:36 -04:00
ZacharyZhang-NYandClaude Opus 5.5 eca8693ac3 Release: build macOS installers on the login-session signing runner
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Failing after 26s
Build Release Installers / Release for the tag (push) Successful in 11s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m14s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m36s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:27:22 -04:00
ZacharyZhang-NYandClaude Opus 5.5 7b07bcea02 Release: fail the macOS job when notarization secrets are set but the Developer ID identity is unreachable
CI / Typecheck, lint, tests, e2e (push) Successful in 3m37s
v1.5.1 shipped ad-hoc macOS installers with only a warning: the runner ran as a system LaunchDaemon, outside the login keychain's session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:24:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 245ff66336 1.5.1: refined office, chat in the app language, current OpenAI models, own connector keys
Build Release Installers / Release for the tag (push) Successful in 16s
Build Release Installers / Windows installer (NSIS x64) (push) Successful in 1m48s
CI / Typecheck, lint, tests, e2e (push) Successful in 3m52s
Build Release Installers / Linux packages (AppImage + deb x64) (push) Successful in 2m21s
Build Release Installers / macOS installers (dmg + zip, x64 + arm64) (push) Successful in 7m15s
- Home: the goal title box is two line boxes tall (2.8em), so a third line no longer peeks out.
- Chat messages, reply fallbacks and assignment DMs follow the app display language; tasks and documents keep the working language.
- OpenAI and GPT-account model lists match OpenAI's current line-up; default gpt-6.1-sol.
- Onboarding connectors step takes the operator's Composio and logo.dev keys, then lists the toolkits the Composio project enables; without a key Connectors leaves the sidebar.
- Google Antigravity removed end to end (migration 111), with the remote-revocation path only it used.
- Office: 2x pixel people with outlines, rim light, walk, typing and blink; dithered real-clock lighting; detailed furniture and floors.
- Per-agent model picker lists the provider's whole model list, live when connected.
- Chat wake context breaks same-millisecond ties by insertion order; the mid-run inbox dedupes by message id.
- The stored "clock" theme reads back instead of falling to "system".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:12:19 -04:00
80 changed files with 1314 additions and 1041 deletions

No files matched your search

+13 -6
View File
@@ -3,8 +3,8 @@ name: Build Release Installers
# A pushed v* tag builds Windows, Linux and macOS installers on the Gitea
# runners and attaches them to the tag's release. The tag must equal
# "v" + package.json version. macOS builds are signed and notarized when the
# Developer ID certificate and the APPLE_API_* secrets are present; otherwise
# they are ad-hoc signed.
# APPLE_API_* secrets are set (the Developer ID identity must then be reachable,
# or the job fails); without those secrets they are ad-hoc signed.
on:
push:
@@ -71,7 +71,8 @@ jobs:
macos:
name: macOS installers (dmg + zip, x64 + arm64)
needs: release
runs-on: macos-latest
# Runs in the login session, where the Developer ID identity lives.
runs-on: macos-signing
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
@@ -90,12 +91,16 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if { [ -n "$CSC_LINK" ] || security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)"; } && [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY_ID" ] && [ -n "$APPLE_API_KEY_P8" ]; then
if [ -z "$APPLE_API_ISSUER" ] || [ -z "$APPLE_API_KEY_ID" ] || [ -z "$APPLE_API_KEY_P8" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::warning::No notarization secrets: building ad-hoc-signed, un-notarized macOS installers."
elif [ -n "$CSC_LINK" ] || security find-identity -v -p codesigning | grep -q "Developer ID Application: Yang Zhang (9PDD8M2SMS)"; then
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "Developer ID certificate and notarization credentials found: building signed + notarized installers."
else
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "::warning::Building ad-hoc-signed, un-notarized macOS installers."
security find-identity -v -p codesigning
echo "::error::Notarization secrets are set but this runner cannot reach the Developer ID identity; run the runner in the login session or set CSC_LINK."
exit 1
fi
- name: Build installers (signed + notarized)
if: steps.signing.outputs.signed == 'true'
@@ -107,6 +112,8 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
# An empty CSC_LINK reads as a certificate path; the keychain signs instead.
[ -n "$CSC_LINK" ] || unset CSC_LINK CSC_KEY_PASSWORD
printf '%s' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY"
trap 'rm -f "$APPLE_API_KEY"' EXIT
pnpm build
-1
View File
@@ -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.
+1 -1
View File
@@ -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.",
+2 -12
View File
@@ -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)));
@@ -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';
`;
+2
View File
@@ -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;
+2 -7
View File
@@ -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
+5 -1
View File
@@ -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({
+18 -14
View File
@@ -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");
@@ -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, unknown>): 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<string> {
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<string, unknown>);
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<void> {
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<OAuthCredentials> {
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<OAuthCredentials> {
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();
}
},
};
-2
View File
@@ -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<string, OAuthFlow> = {
"github-copilot": githubCopilotFlow,
"gpt-account": openaiCodexFlow,
"google-antigravity": antigravityFlow,
"claude-pro": anthropicClaudeFlow,
"kimi-code": kimiCodeFlow,
};
-2
View File
@@ -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 {
+1 -3
View File
@@ -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<string, { field: "accountId" | "projectId"; label: string }> = {
const REQUIRED_EXTRAS: Record<string, { field: "accountId"; label: string }> = {
"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. */
@@ -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<ProviderModelEntry[]>;
getRateLimitState(ctx: ProviderContext): Promise<ProviderRateLimitState | null>;
+1 -23
View File
@@ -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,
@@ -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<ProviderAuthorizeResult>;
export type ProviderRefreshHandler = (input: ProviderRefreshInput) => Promise<ProviderRefreshResult>;
export type ProviderRevokeHandler = (input: ProviderRevokeInput) => Promise<void>;
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);
};
}
@@ -82,11 +82,6 @@ export function requireAccessTokenProfile(profile: ProviderAuthProfile): Extract
return profile;
}
export function requireAccountTokenProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "account_token" }> {
if (profile.type !== "account_token") throw new Error("Account token profile is required.");
return profile;
}
export function requireAzureProfile(profile: ProviderAuthProfile): Extract<ProviderAuthProfile, { type: "azure_openai" }> {
if (profile.type !== "azure_openai") throw new Error("Azure OpenAI profile is required.");
return profile;
@@ -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<string, AbortController>();
constructor(private readonly keys: IntegrationKeyStore) {}
async run(input: { providerKey: string; enterpriseDomain?: string | null }, hooks: ProviderOAuthHooks): Promise<string> {
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 {
@@ -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.
+17 -36
View File
@@ -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<void> {
/** 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 });
@@ -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<OAuthCredentials> {
function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCredentials> {
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<TurnCredential> {
@@ -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<string, Promise<OAuthCredentials>>();
function refreshOnce(deps: { authBroker: AuthBrokerService; integrationKeys: IntegrationKeyStore; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise<OAuthCredentials> {
function refreshOnce(deps: { authBroker: AuthBrokerService; orgId: string; provider: ProviderRow }, creds: StoredOAuth): Promise<OAuthCredentials> {
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;
}
@@ -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<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
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 } } : {}) };
}
@@ -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<string, ToolTurnFn> = {
"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,
+2 -25
View File
@@ -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<AgentTurnResult> {
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<AgentTurnResult> {
@@ -283,7 +260,7 @@ export async function runVertexTurn(input: ProviderToolTurnInput): Promise<Agent
const { projectId, region, serviceAccountJson } = input.credential;
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
const token = await getGcpAccessToken(serviceAccountJson);
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
const body = vertexRequestBody({ instructions: input.instructions, messages: input.messages, tools: input.tools });
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
const response = await postJson(url, { authorization: `Bearer ${token}` }, body, WHOLE_RESPONSE_WAIT);
const parsed = parseGeminiResponse(response.data);
+6 -3
View File
@@ -2,7 +2,9 @@ import { currentDataVersion } from "./data-version";
import { loadViewState } from "./view-state";
import { loadOnboardingDraft } from "./onboarding-progress";
import type { DatabaseSync, StatementSync, SQLInputValue } from "node:sqlite";
import type { AppSnapshot } from "../../shared/domain";
import type { AppSnapshot, UiTheme } from "../../shared/domain";
const UI_THEMES: UiTheme[] = ["system", "light", "dark", "clock"];
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
import { IntegrationKeyStore } from "./integration-key-store";
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
@@ -173,9 +175,10 @@ export class SnapshotService {
private resolveUiPreferences(): AppSnapshot["uiPreferences"] {
const row = this.db.prepare("select theme, language from app_state where id = 1")
.get() as { theme: string; language: string } | undefined;
const theme = row?.theme === "light" || row?.theme === "dark" ? row.theme : "system";
const theme = row?.theme ?? "system";
if (!UI_THEMES.includes(theme as UiTheme)) throw new Error(`Stored theme "${theme}" is not a known theme.`);
const language = row?.language === "zh" ? "zh" : "en";
return { theme, language };
return { theme: theme as UiTheme, language };
}
}
+5 -4
View File
@@ -1,4 +1,4 @@
import { taskWorkLanguage } from "./work-language";
import { appLanguage, taskWorkLanguage } from "./work-language";
import { closeChatWakeTask } from "./chat-wake-close";
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
@@ -187,7 +187,9 @@ export class TaskExecutionService {
.run(effectiveModel(agent.model_id, provider.model), provider.credential_id, runId);
}
const runModel = pinned ?? effectiveModel(agent.model_id, provider.model);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, taskWorkLanguage(this.db, orgId, task.id));
const language = taskWorkLanguage(this.db, orgId, task.id);
const chatLanguage = appLanguage(this.db);
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, task.origin === "chat_wake" ? chatLanguage : language);
// Watermark for reply delivery and mid-run message polling.
const runStartedAt = new Date().toISOString();
const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson);
@@ -208,7 +210,6 @@ export class TaskExecutionService {
const connectorNames = new ConnectorGrantsService(this.db)
.toolkitsForAgent(orgId, agent.id)
.map(connectorDisplayName);
const language = taskWorkLanguage(this.db, orgId, task.id);
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
@@ -222,7 +223,7 @@ export class TaskExecutionService {
orgId,
providerId: provider.id,
workspaceId: task.workspace_id,
instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, connectorNames, language }),
instructions: buildAgentInstructions({ agent, companyName: this.orgName(orgId), isCeo, hasReports, connectorNames, language, chatLanguage }),
initialUserTurn: buildTaskPrompt({
task,
agent,
+7 -2
View File
@@ -97,6 +97,8 @@ export interface AgentInstructionContext {
connectorNames: string[];
/** The task's working language (its own, else the company's). */
language: "en" | "zh";
/** The app's display language: every chat message uses it. */
chatLanguage: "en" | "zh";
}
/**
@@ -113,8 +115,11 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes company documents (PRDs, specs, memos, reports) and list_documents, read_document, update_document find and revise them, search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
input.language === "zh"
? "公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
: "The working language is English: write every message, task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
? "公司的工作语言是简体中文:任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
: "The working language is English: write every task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
input.chatLanguage === "zh"
? "聊天语言是简体中文:你发出的每一条聊天消息(send_message、回复、交接说明)都用中文,即使对方或任务材料用的是其他语言。"
: "The chat language is English: write every chat message you send (send_message, replies, handoff notes) in English, even when the other person or the task material uses another language.",
capabilityBriefing(input),
"Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.",
].join("\n");
+6
View File
@@ -11,3 +11,9 @@ export function taskWorkLanguage(db: DatabaseSync, orgId: string, taskId: string
if (!row) throw new Error("Task was not found.");
return row.language;
}
/** The app's display language; agents chat in it. */
export function appLanguage(db: DatabaseSync): WorkLanguage {
const row = db.prepare("select language from app_state where id = 1").get() as { language: WorkLanguage } | undefined;
return row?.language ?? "en";
}
+3 -2
View File
@@ -12,7 +12,7 @@ import { StartupScreen } from "./StartupScreen";
import type { Focus } from "../pages/types";
import { useMemo, useState } from "react";
import { Shell } from "../shared/Shell";
import { ROUTE_IDS, type RouteId } from "../shared/routes";
import { connectorsEnabled, ROUTE_IDS, type RouteId } from "../shared/routes";
import { AvatarDirectoryContext, type AvatarLook } from "../shared/ui";
import { Onboarding } from "./Onboarding";
import { useSnapshot } from "./useSnapshot";
@@ -77,7 +77,8 @@ export function App() {
return <StartupScreen failure={syncFailure} onRetry={() => 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) {
+17 -9
View File
@@ -198,9 +198,24 @@ export function Onboarding({
</div>
</section>
{step === "connectors" ? (
<div className="onboarding-panel">
<div key={step} className="step-view">
{bootstrappedOrgId && (
<ConnectorsStep
orgId={bootstrappedOrgId}
snapshot={snapshot}
actions={actions}
onFinish={() => finishSetup(bootstrappedOrgId)}
/>
)}
</div>
{error && <div style={{ color: "var(--red)", marginTop: 16, fontSize: "var(--fs-body)" }}>{error}</div>}
</div>
) : (
<form className="onboarding-panel" onSubmit={submit} noValidate>
<div key={step} className="step-view">
{restored && step !== "connectors" && (
{restored && (
<p role="status" style={{ fontSize: "var(--fs-body)", color: "var(--text-2)", margin: "0 0 12px" }}>
{t("Your progress was restored. Keys are never saved before launch, so enter yours again.")}
</p>
@@ -240,17 +255,10 @@ export function Onboarding({
onBack={back("mode")}
/>
)}
{step === "connectors" && bootstrappedOrgId && (
<ConnectorsStep
orgId={bootstrappedOrgId}
snapshot={snapshot}
actions={actions}
onFinish={() => finishSetup(bootstrappedOrgId)}
/>
)}
</div>
{error && <div style={{ color: "var(--red)", marginTop: 16, fontSize: "var(--fs-body)" }}>{error}</div>}
</form>
)}
</main>
</div>
);
@@ -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 (
<div className="form-grid">
<h2 className="page-h1">{t("Connectors")}</h2>
{snapshot.integrationKeys.map((status) => <IntegrationKeyRow key={status.name} status={status} actions={actions} />)}
{enabled && <ConnectorChoices key={composioSavedAt} orgId={orgId} snapshot={snapshot} actions={actions} />}
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button tone="primary" onClick={onFinish}>{enabled ? t("Finish") : t("Skip")}</Button>
</div>
</div>
);
}
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 (
<div className="form-grid">
<h2 className="page-h1">{t("Connectors")}</h2>
<>
{catalog.phase === "error" && <ErrorText message={catalog.message} action={{ label: t("Retry"), onClick: retry }} />}
{catalog.phase === "ready" && (
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
@@ -54,9 +70,6 @@ export function ConnectorsStep({
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
<AccountSyncNote sync={accountSync} onRetry={syncAccounts} />
{connectError && <ErrorText message={connectError} />}
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button tone="primary" onClick={onFinish}>{t("Finish")}</Button>
</div>
</div>
</>
);
}
@@ -13,8 +13,7 @@ curl -o <provider-key>.png "https://img.logo.dev/<domain>?token=<publishable-tok
Domain per file: openai.com, anthropic.com, claude.ai (claude-pro), grok.com
(xai), groq.com, mistral.ai, cerebras.ai, openrouter.ai, moonshot.ai (kimi;
also used for kimi-code — kimi.com is not in logo.dev's index), moonshot.cn
(kimi-cn), github.com (github-copilot), antigravity.google
(google-antigravity), chatgpt.com (gpt-account), azure.microsoft.com
(kimi-cn), github.com (github-copilot), chatgpt.com (gpt-account), azure.microsoft.com
(azure-openai), aws.amazon.com (aws-bedrock), cloud.google.com (vertex-ai).
The custom-openai / custom-anthropic endpoints carry no official brand and
render as letter tiles.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

+1 -3
View File
@@ -134,7 +134,6 @@ export const zh: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
"Filter risk": "筛选风险",
"Filter tasks": "筛选任务",
"Finish": "完成",
"Skip": "跳过",
"Foreground": "前台",
"General": "通用",
"Get models": "获取模型",
+1 -1
View File
@@ -115,7 +115,7 @@ function GoalCard({
return (
<section className="card">
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
<div style={{ fontSize: "var(--fs-body)", lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: 44 }}>
<div style={{ fontSize: "var(--fs-body)", lineHeight: 1.4, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", minHeight: "2.8em" }}>
{goal ? goal.title : t("Set the first goal for {organizationName}.", { organizationName })}
</div>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
+4 -2
View File
@@ -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,
+16 -17
View File
@@ -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) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
</select>
</Field>
<Field label={t("Model")}>
<input className="input" name="modelId" list="hire-model-suggestions" value={hireModelId} disabled={!hireProviderId} placeholder={providers.find((provider) => provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} />
</Field>
<ModelSuggestions id="hire-model-suggestions" providerId={hireProviderId} providers={providers} catalog={snapshot.providerCatalog} />
<AgentModelField
provider={providers.find((provider) => provider.id === hireProviderId) ?? null}
entry={catalogEntry(snapshot.providerCatalog, providers, hireProviderId)}
value={hireModelId}
onChange={setHireModelId}
/>
<p style={{ fontSize: "var(--fs-body)", color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
<Field label={t("Manager")}>
<select className="input" name="managerAgentId" defaultValue="">
@@ -260,7 +263,6 @@ function buildRoster(
return rows;
}
/** The placeholder names the default of the provider picked now, which is the model a blank field will follow. */
function EditModelFields({ agent, providers, catalog }: { agent: AgentRecord; providers: ProviderRecord[]; catalog: ProviderCatalogEntry[] }) {
const [providerId, setProviderId] = useState(agent.modelProviderId ?? "");
const [modelId, setModelId] = useState(agent.modelId ?? "");
@@ -272,23 +274,20 @@ function EditModelFields({ agent, providers, catalog }: { agent: AgentRecord; pr
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
</select>
</Field>
<Field label={t("Model")}>
<input className="input" name="modelId" value={modelId} onChange={(event) => setModelId(event.currentTarget.value)} disabled={!providerId} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="edit-model-suggestions" />
</Field>
<ModelSuggestions id="edit-model-suggestions" providerId={providerId} providers={providers} catalog={catalog} />
<AgentModelField
provider={providers.find((provider) => 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 (
<datalist id={id}>
{models.map((model) => <option key={model.id} value={model.id}>{model.name}</option>)}
</datalist>
);
return catalog.find((entry) => entry.key === providerKey) ?? null;
}
function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) {
+15 -2
View File
@@ -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<T extends Vec>(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;
}
+141
View File
@@ -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";
interface Light {
at: Vec;
z: number;
/** Reach in iso pixels. */
radius: number;
rgb: string;
strength: number;
}
const AMBIENT: Record<DayPart, string> = { day: "rgb(236,232,224)", dawn: "rgb(214,190,196)", dusk: "rgb(222,184,150)", night: "rgb(58,68,112)" };
const LAMPS_ON: Record<DayPart, number> = { day: 0.18, dawn: 0.6, dusk: 0.8, night: 1 };
const SCREEN_RGB: Record<OfficeState, string> = { 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<Rect, OfficeState>): 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;
}
}
}
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);
}
});
}
@@ -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<Record<OfficeState, string>> = {
blocked: "#ef4444",
};
/** The day part washes over the whole floor: warm at dawn and dusk, blue at night. */
const DAY_TINT: Record<DayPart, string | null> = {
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);
+311
View File
@@ -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<Carrying>, 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");
}
@@ -0,0 +1,96 @@
/** An RGBA pixel buffer drawn without anti-aliasing, so procedural art stays exact. */
export class PixelGrid {
readonly data: Uint8ClampedArray<ArrayBuffer>;
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")}`;
}
+86 -16
View File
@@ -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<Season, { leaf: string; light: string }> = {
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),
];
}
+57 -199
View File
@@ -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<string, HTMLCanvasElement>();
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);
}
@@ -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 && <li>{t("Open tasks assigned to them: {count}.", { count: impact.openTasks })}</li>}
<li>{t("Running sessions that stop now: {count}.", { count: impact.runningRuns })}</li>
<li>{t("Queued sessions that will fail when they start: {count}.", { count: impact.queuedRuns })}</li>
<li>{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 })}</li>
<li>{t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}</li>
</ul>
)}
{state.kind === "error" && <div className="form-error" role="alert">{state.message}</div>}
@@ -16,11 +16,6 @@ const KEY_COPY: Record<IntegrationKeyName, { label: string; hint: string; placeh
hint: t("Shows brand logos for connectors. Create one at logo.dev."),
placeholder: "pk_…",
},
google_antigravity_client_secret: {
label: t("Google Antigravity OAuth client secret"),
hint: t("Needed to sign in to and refresh the Google Antigravity provider."),
placeholder: "GOCSPX-…",
},
};
export function IntegrationKeysPanel({ keys, companyName, actions }: { keys: IntegrationKeyStatus[]; companyName: string; actions: SnapshotActions }) {
@@ -31,12 +26,12 @@ export function IntegrationKeysPanel({ keys, companyName, actions }: { keys: Int
<p style={{ margin: "0 0 24px", color: "var(--text-2)" }}>
{t("Keys are stored on this device, encrypted by the operating system, and shared by every company.")}
</p>
{keys.map((status) => <KeyRow key={status.name} status={status} actions={actions} />)}
{keys.map((status) => <IntegrationKeyRow key={status.name} status={status} actions={actions} />)}
</div>
);
}
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<string | null>(null);
@@ -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}
/>
))}
</div>
@@ -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({
<Button
tone="ghost"
onClick={onRevoke}
title={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 })}
title={t("Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.", { provider: provider.label })}
>
{t("Disconnect")}
</Button>
@@ -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<Fetched | null>(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 (
<>
<Field label={t("Model")}>
{!provider || models.length === 0 ? (
<input className="input" name="modelId" value={value} disabled={!provider} placeholder={provider?.model ?? ""} onChange={(event) => onChange(event.currentTarget.value)} />
) : (
<select className="input" name="modelId" value={value} onChange={(event) => onChange(event.currentTarget.value)}>
<option value="">{t("{model} (provider default)", { model: provider.model ?? "" })}</option>
{value && !known && <option value={value}>{value}</option>}
{models.map((model) => <option key={model.id} value={model.id}>{model.name === model.id ? model.id : `${model.name} · ${model.id}`}</option>)}
</select>
)}
</Field>
{live && !mine && <div style={{ color: "var(--text-3)" }}>{t("Loading models...")}</div>}
{mine && "error" in mine && <div className="form-error" role="alert">{mine.error}</div>}
</>
);
}
-2
View File
@@ -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<string, string> = {
"kimi-cn": kimiCnLogo,
"kimi-code": kimiLogo,
"github-copilot": githubCopilotLogo,
"google-antigravity": googleAntigravityLogo,
"gpt-account": gptAccountLogo,
"azure-openai": azureOpenAiLogo,
"aws-bedrock": awsBedrockLogo,
+2 -2
View File
@@ -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({
<span className="search-kbd">{shortcutLabel("K")}</span>
</button>
<nav className="nav">
{allNav.map((item) => (
{allNav.filter((item) => item.id !== "connectors" || connectorsEnabled(snapshot)).map((item) => (
<NavButton key={item.id} item={item} active={route === item.id} badge={badges[item.id]} onSelect={setRoute} />
))}
</nav>
+7
View File
@@ -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);
}
+1 -1
View File
@@ -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];
-2
View File
@@ -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 {
+4 -17
View File
@@ -1,12 +1,12 @@
import { PROVIDER_AUTH_TYPE, type ProviderCatalogEntry } from "./domain";
import { listProviderModels } from "./provider-models";
const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels" | "revokesRemotely">[] = [
const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels">[] = [
{
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<ProviderCatalogEntry, "models" | "supportsLiveModels" |
signInUrl: "https://github.com/login/device",
credentialHelp: "Sign in with the GitHub device code shown here — no token to copy.",
},
{
key: "google-antigravity",
label: "Google Antigravity",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: "claude-sonnet-4-5",
capabilityTags: ["account", "coding"],
signInUrl: "https://antigravity.google/",
credentialHelp: "Sign in with your Google account in the browser; AgentCompany finishes the handshake.",
},
{
key: "gpt-account",
label: "GPT account",
authType: PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION,
defaultModel: "gpt-5.3-codex",
defaultModel: "gpt-6.1-sol",
capabilityTags: ["codex", "subscription"],
signInUrl: "https://chatgpt.com/codex",
credentialHelp: "Sign in to ChatGPT Plus/Pro in the browser; AgentCompany finishes the handshake.",
@@ -183,7 +175,6 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels" |
// list exists) or a free-text model id instead of a live fetch.
const CURATED_MODEL_PROVIDERS = new Set([
"github-copilot",
"google-antigravity",
"gpt-account",
"azure-openai",
"aws-bedrock",
@@ -192,12 +183,8 @@ const CURATED_MODEL_PROVIDERS = new Set([
"custom-anthropic",
]);
// Google documents a token revocation endpoint (RFC 7009); the others offer none for what we hold.
const REMOTE_REVOCATION = new Set(["google-antigravity"]);
export const PROVIDER_CATALOG: ProviderCatalogEntry[] = RAW_CATALOG.map((entry) => ({
...entry,
models: listProviderModels(entry.key),
supportsLiveModels: !CURATED_MODEL_PROVIDERS.has(entry.key),
revokesRemotely: REMOTE_REVOCATION.has(entry.key),
}));
+15 -54
View File
@@ -33,44 +33,18 @@ const ANTHROPIC_MODELS: ProviderModelEntry[] = [
export const PROVIDER_MODELS: Record<string, ProviderModelEntry[]> = {
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<string, ProviderModelEntry[]> = {
{ 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" },
+1 -4
View File
@@ -16,7 +16,6 @@ const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
"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");
}
});
+1 -1
View File
@@ -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.
+23 -12
View File
@@ -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"]) {
+1 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -14,7 +14,7 @@ export async function onboard(page: Page, company: string): Promise<void> {
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();
}
+3 -3
View File
@@ -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.
+1
View File
@@ -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);
+2 -1
View File
@@ -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");
+15
View File
@@ -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);
+5
View File
@@ -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", () => {
+33 -21
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ProviderService } from "../../src/main/services/provider-service";
@@ -9,6 +9,8 @@ import { providerAdapters } from "../../src/main/services/provider-adapters";
import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn";
import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants";
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
import { SkillService } from "../../src/main/services/skill-service";
import { ChatService } from "../../src/main/services/chat-service";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
import { buildAgentInstructions, type TaskExecutionAgentRow } from "../../src/main/services/task-prompts";
import { AGENT_TOOLS } from "../../src/main/services/agent-tools";
@@ -33,13 +35,13 @@ function agentRow(overrides: Partial<TaskExecutionAgentRow>): TaskExecutionAgent
describe("agent instructions", () => {
test("first-hire guidance is one rule everywhere, and new leaders get a leader's voice", () => {
const ceo = buildAgentInstructions({ agent: agentRow({ name: "CEO", role: "CEO", role_level: "c_suite", manager_agent_id: null }), companyName: "O", isCeo: true, hasReports: false, connectorNames: [], language: "en" });
const ceo = buildAgentInstructions({ agent: agentRow({ name: "CEO", role: "CEO", role_level: "c_suite", manager_agent_id: null }), companyName: "O", isCeo: true, hasReports: false, connectorNames: [], language: "en", chatLanguage: "en" });
const hireTool = AGENT_TOOLS.find((tool) => tool.name === "hire_agent");
for (const text of [ceo, goalKickoffDescription("O", "Ship", "Ship it.", { roleLevel: "c_suite", hasReports: false }), hireTool?.description ?? ""]) {
expect(text).toContain(CEO_FIRST_HIRES);
}
const voice = (role_level: string, hasReports: boolean) =>
buildAgentInstructions({ agent: agentRow({ role_level }), companyName: "O", isCeo: false, hasReports, connectorNames: [], language: "en" });
buildAgentInstructions({ agent: agentRow({ role_level }), companyName: "O", isCeo: false, hasReports, connectorNames: [], language: "en", chatLanguage: "en" });
expect(voice("director", false)).toContain('Your department has no one in it yet: hire the managers (hire_agent, seniority "lead") and staff your department needs');
expect(voice("lead", false)).toContain("Your team has no one in it yet: hire the staff your team needs (hire_agent)");
expect(voice("lead", false)).toContain("You lead a team.");
@@ -58,6 +60,7 @@ describe("agent instructions", () => {
hasReports: false,
connectorNames: ["GitHub", "Slack"],
language: "en",
chatLanguage: "en",
});
expect(text).toContain("every connected service");
expect(text).toContain("grant_connector");
@@ -77,6 +80,7 @@ describe("agent instructions", () => {
hasReports: false,
connectorNames: ["Stripe"],
language: "en",
chatLanguage: "en",
});
expect(text).toContain("Connected external services you carry in this run: Stripe");
expect(text).toContain("composio_find_actions");
@@ -93,31 +97,24 @@ describe("agent instructions", () => {
hasReports: false,
connectorNames: [],
language: "en",
chatLanguage: "en",
});
expect(text).toContain("You currently hold no connector grants");
expect(text).toContain("message the CEO");
expect(text).not.toContain("grant before you assign");
});
test("Chinese app language directs agents to work in Chinese", () => {
const zh = buildAgentInstructions({
agent: agentRow({}),
companyName: "OrbitOps",
isCeo: false,
hasReports: false,
connectorNames: [],
language: "zh",
test("chat follows the app language; work follows the working language", () => {
const text = (language: "en" | "zh", chatLanguage: "en" | "zh") => buildAgentInstructions({
agent: agentRow({}), companyName: "OrbitOps", isCeo: false, hasReports: false, connectorNames: [], language, chatLanguage,
});
const mixed = text("en", "zh");
expect(mixed).toContain("The working language is English");
expect(mixed).toContain("聊天语言是简体中文");
const zh = text("zh", "zh");
expect(zh).toContain("公司的工作语言是简体中文");
const en = buildAgentInstructions({
agent: agentRow({}),
companyName: "OrbitOps",
isCeo: false,
hasReports: false,
connectorNames: [],
language: "en",
});
expect(en).not.toContain("简体中文");
expect(zh).not.toContain("The chat language is English");
expect(text("en", "en")).not.toContain("简体中文");
});
});
@@ -178,9 +175,11 @@ describe("agent instruction wiring", () => {
await execution.runTask({ orgId, taskId });
return capturedInstructions;
};
// The display language changes only what the operator sees.
// The display language sets the chat language, not the working language.
expect(await rerun()).toContain("The chat language is English");
organization.updateUiPreferences({ language: "zh" });
expect(await rerun()).toContain("The working language is English");
expect(capturedInstructions).toContain("聊天语言是简体中文");
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
// The company's working language redirects its agents, and a task can keep its own.
connection.db.prepare("update organizations set work_language = 'zh' where id = ?").run(orgId);
@@ -188,6 +187,19 @@ describe("agent instruction wiring", () => {
connection.db.prepare("update tasks set work_language = 'en' where id = ?").run(taskId);
expect(await rerun()).toContain("The working language is English");
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
// A chat reply's house style follows the chat language.
connection.db.prepare("update organizations set work_language = 'en' where id = ?").run(orgId);
const houseStyle = vi.spyOn(SkillService.prototype, "hasRuntimeSkills");
await rerun();
expect(houseStyle.mock.lastCall?.[3]).toBe("en");
const chat = new ChatService(connection.db);
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
const spaceId = chat.ensureDirectSpace(orgId, { type: "employee", id: employeeId }, { type: "agent", id: agentId });
chat.sendMessage({ orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd: "ping", attachmentFileIds: [] });
const wakeRun = connection.db.prepare("select id from task_runs where org_id = ? and trigger = 'chat_mention' and state = 'queued'").get(orgId) as { id: string };
await execution.executeRun(orgId, wakeRun.id);
expect(houseStyle.mock.lastCall?.[3]).toBe("zh");
houseStyle.mockRestore();
});
test("a new department head's task prompt tells them to staff their department, not to work as an IC", async () => {
+1 -1
View File
@@ -36,7 +36,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111]);
connection.close();
});
@@ -0,0 +1,42 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { DatabaseConnection } from "../../src/main/database/connection";
import { dropGoogleAntigravitySchema } from "../../src/main/database/migrations/111-drop-google-antigravity";
import { OrganizationService } from "../../src/main/services/organization-service";
describe("dropping Google Antigravity", () => {
test("removes its providers, frees agents and the auditor, erases its secrets and key", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-drop-ag-${randomUUID()}.db`));
const db = connection.db;
const organization = new OrganizationService(db);
let orgId = "";
let agentId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
db.exec(`
create temp table ag as select * from providers where org_id = '${orgId}' and provider_key = 'openai';
update ag set id = 'prov_ag', provider_key = 'google-antigravity', label = 'Google Antigravity', credential_id = 'cred_ag';
`);
db.prepare("insert into credentials(id, org_id, provider_key, kind, ciphertext, display_hint, created_at) values ('cred_ag', ?, 'google-antigravity', 'oauth', x'0102', 'g', datetime('now'))").run(orgId);
db.exec("insert into providers select * from ag");
agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: "prov_ag", modelId: "gemini-3-flash", budgetCents: 1000, managerAgentId: null });
db.prepare("update organizations set audit_provider_id = 'prov_ag' where id = ?").run(orgId);
db.prepare("insert into integration_keys(name, ciphertext, updated_at) values ('google_antigravity_client_secret', x'01', datetime('now'))").run();
db.prepare("update app_state set onboarding_draft_json = ? where id = 1").run(JSON.stringify({ step: "mission", companyName: "Next", providerKey: "google-antigravity", providerModel: "gemini-3-flash", providerBaseUrl: "" }));
});
db.exec(dropGoogleAntigravitySchema);
expect(db.prepare("select count(*) as n from providers where provider_key = 'google-antigravity'").get()).toEqual({ n: 0 });
expect(db.prepare("select model_provider_id, model_id from agents where id = ?").get(agentId)).toEqual({ model_provider_id: null, model_id: null });
expect(db.prepare("select audit_provider_id from organizations where id = ?").get(orgId)).toEqual({ audit_provider_id: null });
expect(db.prepare("select length(ciphertext) as bytes from credentials where id = 'cred_ag'").get()).toEqual({ bytes: 0 });
expect(db.prepare("select count(*) as n from integration_keys").get()).toEqual({ n: 0 });
expect(db.prepare("select count(*) as n from providers where provider_key = 'openai'").get()).toEqual({ n: 1 });
const draft = JSON.parse((db.prepare("select onboarding_draft_json from app_state where id = 1").get() as { onboarding_draft_json: string }).onboarding_draft_json);
expect(draft).toEqual({ step: "provider", companyName: "Next", providerKey: "openai", providerModel: "gpt-6.1-sol", providerBaseUrl: "" });
connection.close();
});
});
+2 -4
View File
@@ -5,7 +5,7 @@ import { accumulateResponsesStream, parseResponsesOutput } from "../../src/main/
import { accumulateChatStream, parseChatChoice } from "../../src/main/services/provider-tool-turns/chat-format";
import { parseAnthropicContent } from "../../src/main/services/provider-tool-turns/anthropic-format";
import { parseBedrockOutput } from "../../src/main/services/provider-tool-turns/bedrock-format";
import { accumulateGeminiStream, parseGeminiResponse } from "../../src/main/services/provider-tool-turns/gemini-format";
import { parseGeminiResponse } from "../../src/main/services/provider-tool-turns/gemini-format";
import { mapAnthropicMessageStream, mapOpenAiChatCompletionStream, mapOpenAiResponseStream } from "../../src/main/services/provider-streaming";
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
@@ -46,12 +46,10 @@ describe("a turn the provider cut short is never treated as finished", () => {
expect(parseBedrockOutput({ stopReason: "end_turn", output: { message: { content: [{ text: "ok" }] } } }).text).toBe("ok");
});
test("Gemini: MAX_TOKENS fails, a stream without finishReason fails, STOP passes", async () => {
test("Gemini: MAX_TOKENS fails, STOP passes", () => {
const parts = [{ text: "ok" }];
expect(() => parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "MAX_TOKENS" }] })).toThrow("MAX_TOKENS");
expect(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] }).text).toBe("ok");
await expect(accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts } }] } }]))).rejects.toThrow("no stop reason");
expect((await accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts }, finishReason: "STOP" }] } }]))).text).toBe("ok");
});
test("streamed replies: a cut-off stop is an error, not a message_stop", async () => {
-1
View File
@@ -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();
+63
View File
@@ -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);
});
});
+4 -64
View File
@@ -6,10 +6,8 @@ import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ProviderService } from "../../src/main/services/provider-service";
import { providerAdapters } from "../../src/main/services/provider-adapters";
import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog";
import { ERASE_RETIRED_CREDENTIALS_SQL } from "../../src/main/database/migrations/079-erase-retired-credentials";
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver";
@@ -33,43 +31,14 @@ function company(providerKey: string, secret: string, model: string) {
}
describe("disconnecting a provider", () => {
test("only Google Antigravity's sign-in can be revoked at the provider; the catalog says so", () => {
expect(PROVIDER_CATALOG.filter((entry) => entry.revokesRemotely).map((entry) => entry.key)).toEqual(["google-antigravity"]);
});
test("an API key is erased from this computer on disconnect, and a replaced key is erased too", async () => {
vi.stubGlobal("fetch", async () => { throw new Error("no network call expected"); });
const h = company("openai", "sk-first", "gpt-5.4");
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: "sk-second", model: "gpt-5.4" }));
expect(h.ciphertexts()).toEqual([0, 9]);
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
h.connection.transaction(() => h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }));
expect(h.ciphertexts()).toEqual([0, 0]);
expect(h.lastRevocation()).toMatchObject({ providerKey: "openai", remote: "not_supported" });
h.connection.close();
});
test("a Google sign-in is revoked at Google before it is erased here", async () => {
const revoked: string[] = [];
vi.stubGlobal("fetch", async (url: string, init: RequestInit) => {
revoked.push(`${url} ${String(init.body)}`);
return new Response("", { status: 200 });
});
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
expect(revoked).toEqual(["https://oauth2.googleapis.com/revoke token=1%2F%2Frefresh"]);
expect(h.ciphertexts()).toEqual([0]);
expect(h.lastRevocation()).toMatchObject({ remote: "revoked" });
h.connection.close();
});
test("when Google does not confirm, the sign-in is still disconnected and erased here, and the failure is said plainly", async () => {
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
const h = company("google-antigravity", JSON.stringify({ access: "ya29", refresh: "1//refresh", expires: 1_900_000_000_000, projectId: "p" }), "gemini-3-pro");
await expect(h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }))
.rejects.toThrow("Disconnected on this computer, but Google Antigravity did not confirm revoking the sign-in");
expect(h.connection.db.prepare("select enabled, credential_id from providers where id = ?").get(h.providerId)).toEqual({ enabled: 0, credential_id: null });
expect(h.ciphertexts()).toEqual([0]);
expect(h.lastRevocation()).toMatchObject({ remote: "failed", remoteError: expect.stringContaining("400") });
expect(h.lastRevocation()).toEqual({ providerKey: "openai" });
h.connection.close();
});
@@ -77,43 +46,14 @@ describe("disconnecting a provider", () => {
let answer: (response: Response) => void = () => {};
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
const h = company("claude-pro", JSON.stringify({ access: "a", refresh: "r", expires: 0 }), "claude-sonnet-4-5");
const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), integrationKeys: new IntegrationKeyStore(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) });
await h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
const renewal = resolveTurnCredential({ authBroker: new AuthBrokerService(h.connection.db, vault), orgId: h.orgId, provider: loadConfiguredProvider(h.connection.db, h.orgId, h.providerId) });
h.connection.transaction(() => h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId }));
answer(new Response(JSON.stringify({ access_token: "new", refresh_token: "new-r", expires_in: 3600 }), { status: 200 }));
await expect(renewal).rejects.toThrow("disconnected or replaced while it was being renewed");
expect(h.ciphertexts()).toEqual([0]);
h.connection.close();
});
test("a reconnect made while Google answers keeps the new connection and erases only the old sign-in", async () => {
let answer: (response: Response) => void = () => {};
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
answer(new Response("", { status: 200 }));
await expect(disconnecting).rejects.toThrow("was reconnected while it was being disconnected");
expect(h.connection.db.prepare("select enabled from providers where id = ?").get(h.providerId)).toEqual({ enabled: 1 });
expect(h.ciphertexts()).toEqual([0, signIn("1//new").length]);
h.connection.close();
});
test("a reconnect and a refused revocation at once are both reported", async () => {
let answer: (response: Response) => void = () => {};
vi.stubGlobal("fetch", () => new Promise<Response>((resolve) => { answer = resolve; }));
const signIn = (refresh: string) => JSON.stringify({ access: "ya29", refresh, expires: 1_900_000_000_000, projectId: "p" });
const h = company("google-antigravity", signIn("1//old"), "gemini-3-pro");
const disconnecting = h.providers.revokeCredential({ orgId: h.orgId, providerId: h.providerId });
h.connection.transaction(() => h.providers.saveCredential({ orgId: h.orgId, providerId: h.providerId, secret: signIn("1//new"), model: "gemini-3-pro" }));
answer(new Response(JSON.stringify({ error: "invalid_token" }), { status: 400, headers: { "content-type": "application/json" } }));
const failure = (await disconnecting.catch((error: unknown) => error)) as Error;
expect(failure.message).toContain("was reconnected while it was being disconnected");
expect(failure.message).toContain("did not confirm revoking the sign-in (Google revocation failed: 400");
expect(failure.message).toContain("revoke it in your account");
h.connection.close();
});
test("credentials retired before this release are erased by the migration's statement", () => {
const h = company("openai", "sk-old", "gpt-5.4");
h.connection.db.prepare("update providers set credential_id = null where id = ?").run(h.providerId);
-3
View File
@@ -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 () => {
-25
View File
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow";
import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-code-flow";
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow";
import { generatePkce } from "../../src/main/services/oauth/pkce";
import { providerAdapters } from "../../src/main/services/provider-adapters";
@@ -80,28 +79,6 @@ describe("openai codex account id extraction", () => {
});
});
describe("antigravity project discovery", () => {
test("reads a string or object project id", () => {
expect(readProjectId({ cloudaicompanionProject: "proj-a" })).toBe("proj-a");
expect(readProjectId({ cloudaicompanionProject: { id: "proj-b" } })).toBe("proj-b");
});
test("returns null when no project is present", () => {
expect(readProjectId({})).toBeNull();
expect(readProjectId({ cloudaicompanionProject: {} })).toBeNull();
});
});
describe("antigravity oauth configuration", () => {
test("passes a saved client secret through", () => {
expect(requireAntigravityClientSecret("live-secret")).toBe("live-secret");
});
test("requires a client secret saved in Settings", () => {
expect(() => requireAntigravityClientSecret(null)).toThrow("Settings > Integrations");
});
});
describe("pkce generation", () => {
test("produces an S256 challenge for the verifier", () => {
const { verifier, challenge } = generatePkce();
@@ -117,7 +94,6 @@ describe("oauth credential parsing", () => {
test("a stored sign-in is accepted only with what a run needs to use and renew it", () => {
expect(providerAdapters["github-copilot"].parseAuthProfile(signIn())).toEqual({ type: "access_token", accessToken: "live_token" });
expect(providerAdapters["gpt-account"].parseAuthProfile(signIn({ accountId: "acct_1" }))).toEqual({ type: "account_token", accessToken: "live_token" });
expect(providerAdapters["google-antigravity"].parseAuthProfile(signIn({ projectId: "proj-1" }))).toEqual({ type: "access_token", accessToken: "live_token" });
});
test("a pasted token, or a sign-in missing its refresh token, expiry or provider extras, is refused at save time", () => {
@@ -125,7 +101,6 @@ describe("oauth credential parsing", () => {
expect(() => providerAdapters["claude-pro"].parseAuthProfile(JSON.stringify({ access: "a", expires: 1 }))).toThrow("missing its refresh token");
expect(() => providerAdapters["kimi-code"].parseAuthProfile(JSON.stringify({ access: "a", refresh: "r" }))).toThrow("missing its expiry");
expect(() => providerAdapters["gpt-account"].parseAuthProfile(signIn())).toThrow("missing its ChatGPT account id");
expect(() => providerAdapters["google-antigravity"].parseAuthProfile(signIn())).toThrow("missing its Google Cloud project id");
});
});
+7 -60
View File
@@ -7,7 +7,6 @@ import type { AgentTurnMessage } from "../../src/main/services/provider-tool-tur
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "../../src/main/services/provider-tool-turns/responses-format";
import { accumulateChatStream, parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format";
import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format";
import { accumulateGeminiStream, antigravityRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format";
import { parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
import { signAwsRequest } from "../../src/main/services/provider-tool-turns/aws-sigv4";
@@ -19,7 +18,6 @@ import { ProviderService } from "../../src/main/services/provider-service";
import { providerAdapters } from "../../src/main/services/provider-adapters";
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
import { IntegrationKeyStore } from "../../src/main/services/integration-key-store";
const CONVO: AgentTurnMessage[] = [
{ role: "user", text: "do it" },
@@ -135,40 +133,6 @@ describe("anthropic encoding", () => {
});
});
describe("antigravity (gemini) encoding", () => {
test("wraps the request with the Antigravity system instruction and tool declarations", () => {
const body = antigravityRequestBody({ model: "claude-sonnet-4-5", projectId: "proj", instructions: "be helpful", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-1" });
expect(body.project).toBe("proj");
expect(body.requestType).toBe("agent");
expect(body.userAgent).toBe("antigravity");
const request = body.request as JsonRecord;
const system = request.systemInstruction as { role: string; parts: Array<{ text: string }> };
expect(system.role).toBe("user");
expect(system.parts[0].text).toContain("You are Antigravity");
expect(system.parts[system.parts.length - 1].text).toBe("be helpful");
// claude-* models use the legacy `parameters` field
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parameters: { type: "object" } }] }]);
});
test("gemini models use parametersJsonSchema and omit the tool-call id", () => {
const body = antigravityRequestBody({ model: "gemini-3.1-pro-high", projectId: "proj", instructions: null, messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-2" });
const request = body.request as JsonRecord;
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
const contents = request.contents as Array<{ role: string; parts: Array<JsonRecord> }>;
const assistant = contents.find((c) => c.role === "model");
const fnCall = assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord;
expect(fnCall.id).toBeUndefined();
});
test("accumulates streamed candidates", async () => {
const result = await accumulateGeminiStream(stream([
{ response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] }, finishReason: "STOP" }] } },
]));
expect(result.text).toBe("hello");
expect(result.toolCalls).toEqual([{ id: "g1", name: "f", arguments: { x: 1 } }]);
});
});
describe("bedrock converse encoding", () => {
test("encodes toolUse/toolResult and the tool config", () => {
expect(toBedrockMessages(CONVO)).toEqual([
@@ -190,9 +154,11 @@ describe("bedrock converse encoding", () => {
describe("vertex (gemini) encoding", () => {
test("builds a generateContent body with tools and parses the response", () => {
const body = vertexRequestBody({ model: "gemini-2.5-pro", instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
const body = vertexRequestBody({ instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
expect((body.systemInstruction as JsonRecord).parts).toEqual([{ text: "sys" }]);
expect(body.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
const assistant = (body.contents as Array<{ role: string; parts: JsonRecord[] }>).find((c) => c.role === "model");
expect((assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord).id).toBeUndefined();
const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] }, finishReason: "STOP" }] });
expect(parsed).toMatchObject({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] });
});
@@ -214,7 +180,7 @@ describe("aws sigv4", () => {
describe("tool-turn router", () => {
test("supports every agent-capable provider (incl. cloud and custom) and rejects unknown ones", () => {
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
expect(supportsToolTurn(key)).toBe(true);
expect(typeof selectToolTurn(key)).toBe("function");
}
@@ -229,7 +195,6 @@ describe("credential resolver", () => {
const organization = new OrganizationService(connection.db);
const providers = new ProviderService(connection.db, providerAdapters, testVault);
const authBroker = new AuthBrokerService(connection.db, testVault);
const integrationKeys = new IntegrationKeyStore(connection.db, testVault);
let orgId = "";
let openaiId = "";
let gptAccountId = "";
@@ -243,32 +208,14 @@ describe("credential resolver", () => {
});
return (async () => {
const apiKeyCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) });
const apiKeyCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) });
expect(apiKeyCred).toEqual({ apiKey: "sk-live" });
const oauthCred = await resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) });
const oauthCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) });
expect(oauthCred.accessToken).toBe("tok");
expect(oauthCred.accountId).toBe("acct_1");
connection.close();
})();
});
test("an expired Antigravity credential without a saved client secret points to Settings", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
const providers = new ProviderService(connection.db, providerAdapters, testVault);
const authBroker = new AuthBrokerService(connection.db, testVault);
const integrationKeys = new IntegrationKeyStore(connection.db, testVault);
let orgId = "";
let antigravityId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
antigravityId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'google-antigravity'").get(orgId) as { id: string }).id;
providers.saveCredential({ orgId, providerId: antigravityId, secret: JSON.stringify({ access: "old", refresh: "r", expires: 0, projectId: "p" }), model: "gemini-3-pro" });
});
await expect(resolveTurnCredential({ authBroker, integrationKeys, orgId, provider: loadConfiguredProvider(connection.db, orgId, antigravityId) }))
.rejects.toThrow("Settings > Integrations");
connection.close();
});
});
describe("responses tool definitions", () => {
@@ -306,7 +253,7 @@ describe("mid-run follow-up text rides on the tool-result turn", () => {
role: "user",
content: [{ toolResult: { toolUseId: "c1", content: [{ text: "ok" }] } }, { text: "New: stop" }],
});
const gemini = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: convo, tools: [] }) as { contents: Array<{ role: string; parts: unknown[] }> };
const gemini = vertexRequestBody({ instructions: null, messages: convo, tools: [] }) as { contents: Array<{ role: string; parts: unknown[] }> };
expect(gemini.contents.at(-1)).toEqual({
role: "user",
parts: [{ functionResponse: { name: "f", response: { output: "ok" } } }, { text: "New: stop" }],
+2 -4
View File
@@ -5,7 +5,7 @@ import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } fro
import { accumulateChatStream, parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format";
import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format";
import { parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format";
import { accumulateGeminiStream, parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
import { parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) };
@@ -53,10 +53,8 @@ describe("reasoning survives tool turns", () => {
test("Gemini: thought signatures on parts go back, streamed or not", async () => {
const parts = [{ text: "plan", thought: true }, { functionCall: { name: "f", args: {} }, thoughtSignature: "SIG" }];
const vertex = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] })), tools: [] });
const vertex = vertexRequestBody({ instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] })), tools: [] });
expect((vertex.contents as JsonRecord[])[1]).toEqual({ role: "model", parts });
const streamed = await accumulateGeminiStream(stream([...parts.map((part) => ({ response: { candidates: [{ content: { parts: [part] } }] } })), { response: { candidates: [{ finishReason: "STOP" }] } }]));
expect(streamed.replay).toEqual({ format: "gemini", content: parts });
});
test("Chat: streamed reasoning goes back under the field it came in, and OpenRouter details are merged into whole entries", async () => {
+41
View File
@@ -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();
});
});
+1 -2
View File
@@ -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]);
}
+23
View File
@@ -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();
});
});