test(connector-id): migrate fixtures and assertions to pi-mono ids
Replaces legacy adapter ids (codex_local / claude_local / gemini_local / claude_compatible) across ~42 test files with pi-mono equivalents: - codex_local → openai-codex - claude_local → claude-pro - gemini_local → gemini-cli - claude_compatible → anthropic Hand-rewrites where fixture semantics changed: - connector-matching: TASK_TYPE_PREFERENCE ordering rebased (claude-pro first for code/content/management; google first for research) - connector-model-policy: getModels(provider) from pi-ai instead of the deleted adapter.module.models array - session-management + edge-case-session: adapter exemption removed; rotation now uniform under pi-mono - ipc-core-admin-handlers: CSV compat model replaced with pi-ai anthropic - connectors.spec: lookup by openai-codex id (catalog now starts with claude-pro) - onboarding-model-pool: listProviderModels(provider) helper; label text changed to "ChatGPT Plus/Pro (Codex)" - database integration: added vault insecure-fallback env + electron mock - command-paths: uses new @main/connector-helpers normalizeCommandValue - pi-mono-migrations: added `as string` casts for 3 negative-existence assertions verifying legacy ids were removed Drops: - pnpm-workspace.yaml packages: [] (was "packages/*"; directory empty) - obsolete CONNECTOR_STRENGTHS import; .module.models probing Verification: - pnpm typecheck → 0 errors - pnpm lint → 0 errors / 0 warnings - pnpm build:verify → 0 errors / 0 warnings - pnpm test:unit → 508/508 pass - pnpm test:integration → 96/96 pass - All 6 adapter hard gates pass (NOT FOUND / 0 matches / empty) - pi-ai / pi-agent-core / pi-coding-agent all resolved @0.67.6
This commit is contained in:
1 parent
4de9cd0339
commit
e94939a562
80 files changed
+2210
-413
No files matched your search
+21
@@ -0,0 +1,21 @@
|
||||
var createDoomModule = (() => {
|
||||
var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;
|
||||
if (typeof __filename != 'undefined') _scriptName = _scriptName || __filename;
|
||||
return (
|
||||
async function(moduleArg = {}) {
|
||||
var moduleRtn;
|
||||
|
||||
var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WORKER=false;var ENVIRONMENT_IS_NODE=true;if(ENVIRONMENT_IS_NODE){}var moduleOverrides={...Module};var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var EXITSTATUS;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAP64,HEAPU64,HEAPF64;var runtimeInitialized=false;var isFileURI=filename=>filename.startsWith("file://");function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);Module["HEAP64"]=HEAP64=new BigInt64Array(b);Module["HEAPU64"]=HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.initialized)FS.init();TTY.init();wasmExports["__wasm_call_ctors"]();FS.ignorePermissions=false}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}var runDependencies=0;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("doom.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveILine truncated
|
||||
|
||||
|
||||
return moduleRtn;
|
||||
}
|
||||
);
|
||||
})();
|
||||
if (typeof exports === 'object' && typeof module === 'object') {
|
||||
module.exports = createDoomModule;
|
||||
// This default export looks redundant, but it allows TS to import this
|
||||
// commonjs style module.
|
||||
module.exports.default = createDoomModule;
|
||||
} else if (typeof define === 'function' && define['amd'])
|
||||
define([], () => createDoomModule);
|
||||
Executable
BIN
Binary file not shown.
+1
-2
@@ -1,2 +1 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
packages: []
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* Role-classification helpers used across prompt construction and skill
|
||||
* auto-assignment. Keeping a single keyword list here prevents drift
|
||||
* between system-prompt.ts and automation-skills.ts.
|
||||
* auto-assignment. Historical keyword match remains here for backfill and
|
||||
* UI-free fallback; typed `agent.roleLevel` is the source of truth once the
|
||||
* column is populated.
|
||||
*
|
||||
* Matching uses word-boundary tokenization instead of substring search,
|
||||
* so "lead" does not match "cleaner" or "leaderboard".
|
||||
*/
|
||||
import type { AgentRoleLevel } from "@shared/types";
|
||||
import { isLeadershipLevel } from "@shared/types";
|
||||
|
||||
export const LEADERSHIP_ROLE_KEYWORDS = [
|
||||
"chief",
|
||||
@@ -31,3 +34,26 @@ export function roleMatchesLeadershipKeywords(role: string, title?: string | nul
|
||||
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
||||
return LEADERSHIP_ROLE_KEYWORDS.some((keyword) => tokens.has(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot inference from free-text role/title strings. Used when creating or
|
||||
* backfilling an agent so `roleLevel` can be stored once and re-read cheaply.
|
||||
* The most specific token wins; order here is most-senior → least.
|
||||
*/
|
||||
export function inferRoleLevel(role: string, title?: string | null): AgentRoleLevel {
|
||||
const tokens = new Set(tokenize(`${role} ${title ?? ""}`));
|
||||
if (tokens.has("ceo") || tokens.has("cto") || tokens.has("cmo") || tokens.has("coo") || tokens.has("cfo") || tokens.has("chief")) {
|
||||
return "c_suite";
|
||||
}
|
||||
if (tokens.has("vp") || tokens.has("vice") || tokens.has("president")) return "vp";
|
||||
if (tokens.has("director") || tokens.has("head")) return "director";
|
||||
if (tokens.has("manager")) return "manager";
|
||||
if (tokens.has("lead")) return "lead";
|
||||
return "ic";
|
||||
}
|
||||
|
||||
/** Canonical leadership check: typed enum wins, fallback to keyword match. */
|
||||
export function agentIsLeader(agent: { roleLevel?: AgentRoleLevel | null; role: string; title: string | null }): boolean {
|
||||
if (agent.roleLevel) return isLeadershipLevel(agent.roleLevel);
|
||||
return roleMatchesLeadershipKeywords(agent.role, agent.title);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
|
||||
interface BeforeRequestPayload {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
taskId: string | null;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface AfterRequestPayload {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface HumanApprovalHookDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
approvalId: string;
|
||||
resolve: (verdict: { block?: boolean; reason?: string }) => void;
|
||||
}
|
||||
|
||||
const TOOL_LABEL: Record<string, string> = {
|
||||
bash: "shell",
|
||||
edit: "edit",
|
||||
write: "write",
|
||||
read: "read",
|
||||
ls: "ls",
|
||||
grep: "grep",
|
||||
find: "find",
|
||||
};
|
||||
|
||||
function summariseArgs(toolName: string, args: unknown): string {
|
||||
if (args === null || typeof args !== "object") return String(args);
|
||||
const argsObj = args as Record<string, unknown>;
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = typeof argsObj.command === "string" ? argsObj.command : "";
|
||||
return command.length > 200 ? `${command.slice(0, 197)}...` : command;
|
||||
}
|
||||
case "write":
|
||||
case "edit":
|
||||
case "read": {
|
||||
const path = typeof argsObj.path === "string" ? argsObj.path : (typeof argsObj.file_path === "string" ? argsObj.file_path : "");
|
||||
return path;
|
||||
}
|
||||
default:
|
||||
try {
|
||||
const json = JSON.stringify(args);
|
||||
return json.length > 200 ? `${json.slice(0, 197)}...` : json;
|
||||
} catch {
|
||||
return "<unserialisable args>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-in-the-loop hook bridging pi `beforeToolCall` to
|
||||
* AgentCompany's `ApprovalService` via the existing approvals table.
|
||||
*/
|
||||
export class HumanApprovalHook {
|
||||
private readonly pending = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(private readonly deps: HumanApprovalHookDeps) {}
|
||||
|
||||
async before(runId: string, payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
|
||||
const { db, emitEvent } = this.deps;
|
||||
const summary = summariseArgs(payload.toolName, payload.args);
|
||||
const label = TOOL_LABEL[payload.toolName] ?? payload.toolName;
|
||||
const approvalId = db.requestApproval({
|
||||
companyId: payload.companyId,
|
||||
relatedTaskId: payload.taskId,
|
||||
requestedByAgentId: payload.agentId,
|
||||
type: "dangerous_command",
|
||||
payloadSummary: `${label}: ${summary}`.slice(0, 280),
|
||||
impactSummary: `Agent ${payload.agentId} requested ${label} during run ${runId}.`,
|
||||
payloadJson: JSON.stringify({
|
||||
runId,
|
||||
toolCallId: payload.toolCallId,
|
||||
toolName: payload.toolName,
|
||||
args: payload.args,
|
||||
}),
|
||||
});
|
||||
|
||||
emitEvent({ type: "approval-updated", approvalId, action: "created" });
|
||||
emitEvent({ type: "domain-changed" });
|
||||
|
||||
return new Promise<{ block?: boolean; reason?: string }>((resolve) => {
|
||||
this.pending.set(approvalId, { approvalId, resolve });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the IPC approval-decided pipeline when a user resolves a
|
||||
* pending approval. If we have a pending pi `beforeToolCall` waiting on
|
||||
* this approval, resolve it.
|
||||
*/
|
||||
resolveApproval(approvalId: string, decision: "approve" | "reject" | "revision_requested", note: string): boolean {
|
||||
const pending = this.pending.get(approvalId);
|
||||
if (!pending) return false;
|
||||
this.pending.delete(approvalId);
|
||||
if (decision === "approve") {
|
||||
pending.resolve({});
|
||||
} else {
|
||||
pending.resolve({ block: true, reason: note || `Tool execution ${decision}.` });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Called when a run is cancelled — resolves any pending approvals as blocked. */
|
||||
cancelAll(reason: string): void {
|
||||
for (const [id, pending] of this.pending) {
|
||||
pending.resolve({ block: true, reason });
|
||||
this.pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
|
||||
// Audit hook intentionally minimal — actual tool result is captured by
|
||||
// the agent-event stream and persisted via SqliteSessionStore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { SqliteSessionStore } from "./sqlite-session-store";
|
||||
export { HumanApprovalHook } from "./approval-hook";
|
||||
export { CostWriter } from "./cost-writer";
|
||||
export { RuntimeBridge } from "./runtime-bridge";
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AppDatabase } from "@main/database";
|
||||
|
||||
/**
|
||||
* SQLite-backed pi `AgentMessage[]` store.
|
||||
*
|
||||
* One store instance per task_session. Owners must call `appendMessage`
|
||||
* exactly once per pi `message_end` event, and `replaceMessages` after pi
|
||||
* `compact` returns a compacted message list.
|
||||
*/
|
||||
export class SqliteSessionStore {
|
||||
constructor(
|
||||
private readonly db: AppDatabase,
|
||||
public readonly taskSessionId: string,
|
||||
) {}
|
||||
|
||||
loadMessages(): AgentMessage[] {
|
||||
const rows = this.db.sessionEntries.listActive(this.taskSessionId);
|
||||
const result: AgentMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.kind === "message") {
|
||||
try {
|
||||
result.push(JSON.parse(row.payloadJson) as AgentMessage);
|
||||
} catch {
|
||||
// Skip malformed entries (defensive) — preserves the rest of the
|
||||
// transcript instead of failing the entire load.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
appendMessage(message: AgentMessage): void {
|
||||
this.db.sessionEntries.append({
|
||||
taskSessionId: this.taskSessionId,
|
||||
kind: "message",
|
||||
role: (message as { role?: string }).role ?? null,
|
||||
payloadJson: JSON.stringify(message),
|
||||
});
|
||||
}
|
||||
|
||||
appendCompaction(summary: string): void {
|
||||
this.db.sessionEntries.append({
|
||||
taskSessionId: this.taskSessionId,
|
||||
kind: "compaction",
|
||||
role: null,
|
||||
payloadJson: JSON.stringify({ summary }),
|
||||
});
|
||||
}
|
||||
|
||||
replaceMessages(messages: AgentMessage[], compactionSummary?: string): void {
|
||||
const entries: Array<{ kind: "message" | "compaction"; role: string | null; payloadJson: string }> = [];
|
||||
if (compactionSummary) {
|
||||
entries.push({ kind: "compaction", role: null, payloadJson: JSON.stringify({ summary: compactionSummary }) });
|
||||
}
|
||||
for (const message of messages) {
|
||||
entries.push({
|
||||
kind: "message",
|
||||
role: (message as { role?: string }).role ?? null,
|
||||
payloadJson: JSON.stringify(message),
|
||||
});
|
||||
}
|
||||
this.db.sessionEntries.replace({ taskSessionId: this.taskSessionId, entries });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AppDatabase } from "@main/database";
|
||||
import type { CredentialBackend, CredentialKind } from "@main/database/credentials";
|
||||
import type { CredentialStatus } from "@main/database/credentials";
|
||||
import type { CredentialSummary } from "@shared/contracts/schemas-credentials";
|
||||
import type { StoredCredentialPayload } from "./types";
|
||||
|
||||
const HINT_SUFFIX_LEN = 4;
|
||||
const HINT_PREFIX_LEN = 6;
|
||||
|
||||
function maskApiKey(value: string): string {
|
||||
if (value.length <= HINT_PREFIX_LEN + HINT_SUFFIX_LEN) {
|
||||
return `${value.slice(0, 2)}...`;
|
||||
}
|
||||
const prefix = value.slice(0, HINT_PREFIX_LEN);
|
||||
const suffix = value.slice(-HINT_SUFFIX_LEN);
|
||||
return `${prefix}...${suffix}`;
|
||||
}
|
||||
|
||||
export function deriveDisplayHint(payload: StoredCredentialPayload): string {
|
||||
switch (payload.kind) {
|
||||
case "api_key":
|
||||
return payload.value.source === "literal"
|
||||
? maskApiKey(payload.value.value)
|
||||
: `env:${payload.value.value}`;
|
||||
case "azure":
|
||||
return payload.value.resourceName
|
||||
? `azure:${payload.value.resourceName}`
|
||||
: `azure:${maskApiKey(payload.value.apiKey)}`;
|
||||
case "aws":
|
||||
if (payload.value.authMode === "profile") return `aws:profile=${payload.value.awsProfile ?? "?"}`;
|
||||
if (payload.value.authMode === "iam") return `aws:iam=${maskApiKey(payload.value.accessKeyId ?? "?")}`;
|
||||
return `aws:bearer`;
|
||||
case "gcp_adc":
|
||||
return `gcp:${payload.value.project}@${payload.value.location}`;
|
||||
case "oauth": {
|
||||
const expiresMs = payload.value.expires;
|
||||
const expiresLabel = typeof expiresMs === "number" && Number.isFinite(expiresMs)
|
||||
? new Date(expiresMs).toISOString()
|
||||
: "no-expiry";
|
||||
return `oauth:expires=${expiresLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface CredentialStoreDeps {
|
||||
db: AppDatabase;
|
||||
}
|
||||
|
||||
export class CredentialStore {
|
||||
constructor(private readonly deps: CredentialStoreDeps) {}
|
||||
|
||||
list(): CredentialSummary[] {
|
||||
return this.deps.db.credentialsTable.list().map((row) => ({
|
||||
connectorId: row.connectorId,
|
||||
kind: row.kind as CredentialKind,
|
||||
displayHint: row.displayHint,
|
||||
status: row.status,
|
||||
expiresAt: row.expiresAt,
|
||||
lastRefreshedAt: row.lastRefreshedAt,
|
||||
lastError: row.lastError,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Encrypts and stores a credential. Caller is responsible for kind/connectorId match. */
|
||||
save(connectorId: string, payload: StoredCredentialPayload, expiresAt: string | null = null): CredentialSummary {
|
||||
const json = JSON.stringify(payload.value);
|
||||
const { backend, payload: ciphertext } = this.deps.db.vault.encrypt(json);
|
||||
const stored = this.deps.db.credentialsTable.upsert({
|
||||
connectorId,
|
||||
kind: payload.kind,
|
||||
backend: backend as CredentialBackend,
|
||||
ciphertext,
|
||||
displayHint: deriveDisplayHint(payload),
|
||||
status: "active",
|
||||
expiresAt,
|
||||
lastRefreshedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
return {
|
||||
connectorId: stored.connectorId,
|
||||
kind: stored.kind,
|
||||
displayHint: stored.displayHint,
|
||||
status: stored.status,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastRefreshedAt: stored.lastRefreshedAt,
|
||||
lastError: stored.lastError,
|
||||
updatedAt: stored.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decrypts and returns the typed credential payload. Returns null when missing. */
|
||||
read(connectorId: string): StoredCredentialPayload | null {
|
||||
const row = this.deps.db.credentialsTable.get(connectorId);
|
||||
if (!row) return null;
|
||||
const json = this.deps.db.vault.decrypt(
|
||||
row.backend,
|
||||
Buffer.isBuffer(row.ciphertext) ? row.ciphertext : Buffer.from(row.ciphertext),
|
||||
);
|
||||
const value = JSON.parse(json);
|
||||
return { kind: row.kind, value } as StoredCredentialPayload;
|
||||
}
|
||||
|
||||
remove(connectorId: string): void {
|
||||
this.deps.db.credentialsTable.remove(connectorId);
|
||||
}
|
||||
|
||||
setStatus(connectorId: string, status: CredentialStatus, lastError: string | null = null): void {
|
||||
this.deps.db.credentialsTable.setStatus(connectorId, status, lastError);
|
||||
}
|
||||
|
||||
/** Replaces the OAuth ciphertext after a successful refresh. */
|
||||
saveRefreshedOAuth(connectorId: string, payload: StoredCredentialPayload, expiresAt: string | null): void {
|
||||
const { backend, payload: ciphertext } = this.deps.db.vault.encrypt(JSON.stringify(payload.value));
|
||||
this.deps.db.credentialsTable.markRefreshed(
|
||||
connectorId,
|
||||
backend as CredentialBackend,
|
||||
ciphertext,
|
||||
expiresAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { CredentialStore, deriveDisplayHint } from "./credential-store";
|
||||
export { CredentialResolver, CredentialError, type ResolvedCredential } from "./credential-resolver";
|
||||
export { OAuthLoginService } from "./oauth-login-service";
|
||||
export type { StoredCredentialPayload, OAuthCredentialPayload, CredentialKind } from "./types";
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ApiKeyValue,
|
||||
AwsCredential,
|
||||
AzureCredential,
|
||||
GcpAdcCredential,
|
||||
} from "@shared/contracts/schemas-credentials";
|
||||
|
||||
/**
|
||||
* pi-ai's `OAuthCredentials` shape.
|
||||
*
|
||||
* Mirrored locally as a type-only declaration to avoid pulling pi runtime
|
||||
* symbols into modules that only need the shape. The actual contract is
|
||||
* enforced by pi at runtime via the OAuth provider interface.
|
||||
*/
|
||||
export interface OAuthCredentialPayload {
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
/** Provider-specific extra fields (e.g. github copilot domain) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type StoredCredentialPayload =
|
||||
| { kind: "oauth"; value: OAuthCredentialPayload }
|
||||
| { kind: "api_key"; value: ApiKeyValue }
|
||||
| { kind: "azure"; value: AzureCredential }
|
||||
| { kind: "aws"; value: AwsCredential }
|
||||
| { kind: "gcp_adc"; value: GcpAdcCredential };
|
||||
|
||||
export type CredentialKind = StoredCredentialPayload["kind"];
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { STALE_LOCK_TIMEOUT_MS } from "./database-base";
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-state recovery helpers extracted from `database-base.ts` to keep that
|
||||
* file under the 400-line cap. Both helpers are called by `AppDatabase.init`
|
||||
* (interruptStaleRuns) and the orchestration recovery loop (recoverStaleLocks).
|
||||
*/
|
||||
|
||||
interface InterruptDeps {
|
||||
db: DatabaseSync;
|
||||
transaction: <T>(op: () => T) => T;
|
||||
releaseAllWorkspaceLocks: () => void;
|
||||
}
|
||||
|
||||
export function interruptStaleRunsImpl(deps: InterruptDeps): number {
|
||||
const ts = nowIso();
|
||||
let interruptedCount = 0;
|
||||
deps.transaction(() => {
|
||||
const result = deps.db
|
||||
.prepare(
|
||||
"update runs set status = 'interrupted', error_message = 'AgentCompany restarted before the run could report completion.', finished_at = ?, updated_at = ? where status = 'running'",
|
||||
)
|
||||
.run(ts, ts);
|
||||
interruptedCount = Number(result.changes);
|
||||
if (interruptedCount > 0) {
|
||||
deps.db
|
||||
.prepare(
|
||||
"update tasks set status = 'blocked', updated_at = ? where id in (select task_id from runs where status = 'interrupted' and finished_at = ?) and status in ('in_progress', 'todo')",
|
||||
)
|
||||
.run(ts, ts);
|
||||
}
|
||||
deps.db
|
||||
.prepare(
|
||||
"update tasks set active_run_id = null where active_run_id in (select id from runs where status = 'interrupted')",
|
||||
)
|
||||
.run();
|
||||
deps.db.prepare("update agents set status = 'idle', updated_at = ? where status = 'running'").run(ts);
|
||||
deps.releaseAllWorkspaceLocks();
|
||||
});
|
||||
return interruptedCount;
|
||||
}
|
||||
|
||||
interface RecoverDeps {
|
||||
db: DatabaseSync;
|
||||
readAll: <T>(sql: string, ...params: import("node:sqlite").SQLInputValue[]) => T[];
|
||||
}
|
||||
|
||||
export function recoverStaleLocksImpl(
|
||||
deps: RecoverDeps,
|
||||
): Array<{ taskId: string; companyId: string; title: string }> {
|
||||
const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString();
|
||||
const stale = deps.readAll<{ id: string; companyId: string; title: string }>(
|
||||
"SELECT id, company_id AS companyId, title FROM tasks WHERE execution_locked_at IS NOT NULL AND execution_locked_at < ? AND status = 'in_progress' AND checkout_run_id NOT IN (SELECT id FROM runs WHERE status = 'running')",
|
||||
cutoff,
|
||||
);
|
||||
const ts = nowIso();
|
||||
for (const task of stale) {
|
||||
deps.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, status = 'todo', updated_at = ? WHERE id = ?",
|
||||
)
|
||||
.run(ts, task.id);
|
||||
}
|
||||
return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title }));
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function buildEntityCrudMethods(
|
||||
getAgent(id: string): AgentRecord {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) throw new Error(`Agent not found: ${id}`);
|
||||
@@ -74,7 +74,7 @@ export function buildEntityCrudMethods(
|
||||
findAgent(id: string): AgentRecord | null {
|
||||
const row = getDb()
|
||||
.prepare(
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where id = ?",
|
||||
)
|
||||
.get(id) as AgentRecord | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildSnapshotMethods(
|
||||
...companyArgs,
|
||||
).map((record) => ({ ...record, isPrimary: toBoolean(record.isPrimary) })) as WorkspaceRecord[],
|
||||
agents: readAll<SqliteRow>(
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
`select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, role_level as roleLevel, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents${companyWhere} order by created_at asc`,
|
||||
...companyArgs,
|
||||
).map((record: SqliteRow) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[],
|
||||
goals: readAll<GoalRecord>(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Resolves `Model<Api>` and the dynamic stream headers needed for providers
|
||||
* that pi-ai's catalog does not ship out of the box.
|
||||
*
|
||||
* Today this only covers the two Fireworks connectors (`fireworks` and
|
||||
* `fireworks-firepass`), which both speak the Anthropic-compatible endpoint
|
||||
* at `https://api.fireworks.ai/inference` and require Bearer auth. For every
|
||||
* other provider we delegate to pi-ai's `getModel` / `getModels`.
|
||||
*/
|
||||
|
||||
import { getModel, getModels, type Api, type Model } from "@mariozechner/pi-ai";
|
||||
|
||||
const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference";
|
||||
const FIREWORKS_DEFAULT_CONTEXT = 128_000;
|
||||
const FIREWORKS_DEFAULT_MAX_TOKENS = 8192;
|
||||
|
||||
export const FIREWORKS_FIREPASS_MODEL_ID = "accounts/fireworks/routers/kimi-k2p5-turbo";
|
||||
export const FIREWORKS_FIREPASS_MODEL_NAME = "Kimi K2.5 Turbo (Fire Pass)";
|
||||
|
||||
const FIREWORKS_CURATED_MODELS: Array<{ id: string; name: string; contextWindow?: number }> = [
|
||||
{ id: "accounts/fireworks/models/kimi-k2-instruct", name: "Kimi K2 Instruct", contextWindow: 131_072 },
|
||||
{ id: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct", name: "Qwen3 Coder 480B A35B Instruct", contextWindow: 262_144 },
|
||||
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1", contextWindow: 163_840 },
|
||||
{ id: "accounts/fireworks/models/glm-4p5", name: "GLM 4.5", contextWindow: 131_072 },
|
||||
{ id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B Instruct", contextWindow: 131_072 },
|
||||
];
|
||||
|
||||
function buildFireworksModel(modelId: string, name?: string, contextWindow?: number): Model<"anthropic-messages"> {
|
||||
return {
|
||||
id: modelId,
|
||||
name: name ?? modelId,
|
||||
api: "anthropic-messages",
|
||||
provider: "fireworks",
|
||||
baseUrl: FIREWORKS_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: contextWindow ?? FIREWORKS_DEFAULT_CONTEXT,
|
||||
maxTokens: FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function isFireworksProvider(provider: string): boolean {
|
||||
return provider === "fireworks" || provider === "fireworks-firepass";
|
||||
}
|
||||
|
||||
export function resolveModel(provider: string, modelId: string): Model<Api> | null {
|
||||
if (provider === "fireworks-firepass") {
|
||||
return buildFireworksModel(FIREWORKS_FIREPASS_MODEL_ID, FIREWORKS_FIREPASS_MODEL_NAME, 262_144);
|
||||
}
|
||||
if (provider === "fireworks") {
|
||||
const curated = FIREWORKS_CURATED_MODELS.find((entry) => entry.id === modelId);
|
||||
return buildFireworksModel(modelId, curated?.name, curated?.contextWindow);
|
||||
}
|
||||
try {
|
||||
return getModel(provider as never, modelId as never) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveModels(provider: string): Array<{ id: string; name: string }> {
|
||||
if (provider === "fireworks-firepass") {
|
||||
return [{ id: FIREWORKS_FIREPASS_MODEL_ID, name: FIREWORKS_FIREPASS_MODEL_NAME }];
|
||||
}
|
||||
if (provider === "fireworks") {
|
||||
return FIREWORKS_CURATED_MODELS.map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
try {
|
||||
const models = getModels(provider as never) ?? [];
|
||||
return models.map((model: { id: string; name?: string }) => ({ id: model.id, name: model.name ?? model.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra HTTP headers that must be merged into the streamSimple call for the
|
||||
* given provider. Returns undefined when no extra headers are required.
|
||||
*
|
||||
* Fireworks' Anthropic-compat endpoint authenticates via `Authorization: Bearer`,
|
||||
* not `x-api-key`. pi-ai's anthropic provider only sends `x-api-key`, so we
|
||||
* inject the bearer header at the call site.
|
||||
*/
|
||||
export function resolveExtraStreamHeaders(provider: string, apiKey: string): Record<string, string> | undefined {
|
||||
if (!apiKey) return undefined;
|
||||
if (isFireworksProvider(provider)) {
|
||||
return { Authorization: `Bearer ${apiKey}` };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type log from "electron-log/main.js";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
interface ResumeInterruptedTasksDeps {
|
||||
db: AppDatabase;
|
||||
logger: typeof log;
|
||||
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => string | null;
|
||||
publishDomainChanged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume tasks that were left behind by a clean shutdown.
|
||||
*
|
||||
* The worker-service marks running/queued runs as "interrupted" with a fixed
|
||||
* summary string on shutdown, but does not reset the owning task. This helper
|
||||
* rolls those tasks back to "todo", releases their run locks, and re-wakes the
|
||||
* assigned agents so heartbeat can pick them up again.
|
||||
*/
|
||||
export function resumeInterruptedTasks({
|
||||
db,
|
||||
logger,
|
||||
wakeAgentIfPossible,
|
||||
publishDomainChanged,
|
||||
}: ResumeInterruptedTasksDeps): void {
|
||||
try {
|
||||
const snapshot = db.listSnapshot();
|
||||
const shutdownRuns = snapshot.runs.filter(
|
||||
(r) =>
|
||||
r.status === "interrupted" &&
|
||||
(r.summary === "Application shut down." || r.summary === "Worker process crashed."),
|
||||
);
|
||||
if (shutdownRuns.length === 0) return;
|
||||
|
||||
const resumedAgents = new Set<string>();
|
||||
for (const run of shutdownRuns) {
|
||||
try {
|
||||
const task = db.getTask(run.taskId);
|
||||
if (task.status === "in_progress" || task.status === "blocked") {
|
||||
db.advanceTaskStatus(run.taskId, "todo", "Auto-resumed after app restart.");
|
||||
db.releaseTaskLock(run.taskId, run.id);
|
||||
}
|
||||
if (!resumedAgents.has(run.agentId)) {
|
||||
resumedAgents.add(run.agentId);
|
||||
wakeAgentIfPossible(run.agentId, run.companyId, "app_restart_resume");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[bootstrap] Skipped shutdown recovery for run ${run.id} (task/agent may have been deleted):`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resumedAgents.size > 0) {
|
||||
logger.info(
|
||||
`[bootstrap] Resumed ${shutdownRuns.length} interrupted task(s) across ${resumedAgents.size} agent(s) after clean shutdown recovery.`,
|
||||
);
|
||||
publishDomainChanged();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[bootstrap] Shutdown recovery failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Helpers for assembling orchestration-service dependencies in service-graph.ts.
|
||||
* Extracted to keep service-graph.ts under the 400-line cap.
|
||||
*/
|
||||
|
||||
import type { AppDatabase } from "./database";
|
||||
import { createSkillService } from "./skill-service";
|
||||
import type { createSessionManagementService } from "./session-management-service";
|
||||
import type { RuntimeSkillEntry } from "./skill-types";
|
||||
|
||||
interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function makeListRuntimeSkillEntries(deps: { db: AppDatabase; logger: Logger }) {
|
||||
let cache: ReturnType<typeof createSkillService> | null = null;
|
||||
return (companyId: string, agentId: string, connectorId: string): RuntimeSkillEntry[] => {
|
||||
try {
|
||||
if (!cache) cache = createSkillService({ db: deps.db, profileDir: deps.db.profileDir, logger: deps.logger });
|
||||
return cache.listRuntimeSkillEntries(companyId, agentId, connectorId);
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] listRuntimeSkillEntries failed for agent ${agentId}:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSessionHandoffLookup(
|
||||
deps: { logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string): string | null => {
|
||||
try {
|
||||
return getSessionService().getHandoffContext(taskId);
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] getSessionHandoff failed for task ${taskId}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function makePreRunRotationEvaluator(
|
||||
deps: { db: AppDatabase; logger: Logger },
|
||||
getSessionService: () => ReturnType<typeof createSessionManagementService>,
|
||||
) {
|
||||
return (taskId: string, connectorId: string): { rotated: boolean; handoff: string | null } => {
|
||||
try {
|
||||
const sessionSvc = getSessionService();
|
||||
const activeSession = deps.db.getActiveTaskSession(taskId);
|
||||
if (!activeSession) return { rotated: false, handoff: null };
|
||||
const result = sessionSvc.evaluateRotation(activeSession, connectorId);
|
||||
if (result.shouldRotate) {
|
||||
sessionSvc.rotateSession(activeSession);
|
||||
const handoff = sessionSvc.getHandoffContext(taskId);
|
||||
return { rotated: true, handoff };
|
||||
}
|
||||
return { rotated: false, handoff: null };
|
||||
} catch (error) {
|
||||
deps.logger.warn(`[service-graph] evaluatePreRunSessionRotation failed for task ${taskId}:`, error);
|
||||
return { rotated: false, handoff: null };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Pi-mono runtime + credential service factory.
|
||||
*
|
||||
* Composes `CredentialStore`, `CredentialResolver`, `OAuthLoginService`, and
|
||||
* `RuntimeBridge` for `service-graph.ts`. Extracted to keep `service-graph.ts`
|
||||
* under the 400-line cap.
|
||||
*/
|
||||
|
||||
import type { AppDatabase } from "./database";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { CredentialResolver, CredentialStore, OAuthLoginService } from "./credentials";
|
||||
import { RuntimeBridge } from "./agent-runtime";
|
||||
|
||||
export interface PiRuntimeServicesDeps {
|
||||
db: AppDatabase;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
refreshConnectorHealth: (connectorId: string) => Promise<void>;
|
||||
logger: {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiRuntimeServices {
|
||||
credentialStore: CredentialStore;
|
||||
credentialResolver: CredentialResolver;
|
||||
oauthLoginService: OAuthLoginService;
|
||||
runtimeBridge: RuntimeBridge;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export function createPiRuntimeServices(deps: PiRuntimeServicesDeps): PiRuntimeServices {
|
||||
const credentialStore = new CredentialStore({ db: deps.db });
|
||||
const credentialResolver = new CredentialResolver(credentialStore);
|
||||
const oauthLoginService = new OAuthLoginService({
|
||||
store: credentialStore,
|
||||
emitEvent: deps.emitEvent,
|
||||
refreshConnectorHealth: deps.refreshConnectorHealth,
|
||||
logger: deps.logger,
|
||||
});
|
||||
const runtimeBridge = new RuntimeBridge({
|
||||
db: deps.db,
|
||||
emitEvent: deps.emitEvent,
|
||||
logger: deps.logger,
|
||||
credentialResolver,
|
||||
});
|
||||
|
||||
return {
|
||||
credentialStore,
|
||||
credentialResolver,
|
||||
oauthLoginService,
|
||||
runtimeBridge,
|
||||
release: () => {
|
||||
runtimeBridge.approvalHook.cancelAll("AgentCompany shutting down.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Silences the `node:sqlite` ExperimentalWarning emitted on first import.
|
||||
* Must be imported before any module that touches `node:sqlite`, otherwise
|
||||
* the warning has already fired.
|
||||
*
|
||||
* Targeted: only the SQLite experimental warning. All other warnings pass
|
||||
* through unchanged.
|
||||
*/
|
||||
|
||||
const originalEmitWarning = process.emitWarning.bind(process);
|
||||
|
||||
function isSqliteExperimentalWarning(warning: unknown): boolean {
|
||||
if (typeof warning === "string") {
|
||||
return /SQLite is an experimental feature/.test(warning);
|
||||
}
|
||||
if (warning instanceof Error) {
|
||||
return warning.name === "ExperimentalWarning" && /SQLite/.test(warning.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
process.emitWarning = ((warning: unknown, ...rest: unknown[]) => {
|
||||
if (isSqliteExperimentalWarning(warning)) return;
|
||||
return (originalEmitWarning as (...args: unknown[]) => void)(warning, ...rest);
|
||||
}) as typeof process.emitWarning;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Defensive token redactor for worker process logs.
|
||||
*
|
||||
* Pi-mono's HTTP clients are not known to log credentials, but stack traces
|
||||
* from upstream libraries (or future pi versions) could include header
|
||||
* fragments containing `Authorization: Bearer ...` or `?access_token=...`.
|
||||
* To prevent any such fragment from reaching the parent's stderr stream,
|
||||
* `installLogRedactor()` wraps `console.error`, `console.warn`, `console.log`,
|
||||
* `process.stdout.write`, and `process.stderr.write` so that every string
|
||||
* argument is filtered through `redactTokens()` before transmission.
|
||||
*/
|
||||
|
||||
const PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
{ pattern: /Bearer\s+[A-Za-z0-9._\-+/=]{8,}/g, replacement: "Bearer [REDACTED]" },
|
||||
{ pattern: /(authorization\s*[:=]\s*)([^\s,;]{8,})/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(access_token=)([^&\s"']+)/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(refresh_token=)([^&\s"']+)/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /(api[_-]?key\s*[:=]\s*['"]?)([A-Za-z0-9._-]{12,})/gi, replacement: "$1[REDACTED]" },
|
||||
{ pattern: /sk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replacement: "sk-[REDACTED]" },
|
||||
{ pattern: /ghp_[A-Za-z0-9]{16,}/g, replacement: "ghp_[REDACTED]" },
|
||||
{ pattern: /gho_[A-Za-z0-9]{16,}/g, replacement: "gho_[REDACTED]" },
|
||||
{ pattern: /AKIA[A-Z0-9]{16}/g, replacement: "AKIA[REDACTED]" },
|
||||
];
|
||||
|
||||
export function redactTokens(text: string): string {
|
||||
let result = text;
|
||||
for (const { pattern, replacement } of PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function redactArg(arg: unknown): unknown {
|
||||
if (typeof arg === "string") return redactTokens(arg);
|
||||
if (arg instanceof Error) {
|
||||
const redacted = new Error(redactTokens(arg.message));
|
||||
redacted.name = arg.name;
|
||||
if (arg.stack) redacted.stack = redactTokens(arg.stack);
|
||||
return redacted;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
|
||||
interface InstallTarget {
|
||||
console: { log: typeof console.log; warn: typeof console.warn; error: typeof console.error; info: typeof console.info };
|
||||
stdoutWrite: typeof process.stdout.write;
|
||||
stderrWrite: typeof process.stderr.write;
|
||||
}
|
||||
|
||||
export function installLogRedactor(target?: Partial<InstallTarget>): () => void {
|
||||
const cons = target?.console ?? console;
|
||||
const origLog = cons.log.bind(cons);
|
||||
const origWarn = cons.warn.bind(cons);
|
||||
const origError = cons.error.bind(cons);
|
||||
const origInfo = cons.info.bind(cons);
|
||||
|
||||
cons.log = (...args: unknown[]) => origLog(...args.map(redactArg));
|
||||
cons.warn = (...args: unknown[]) => origWarn(...args.map(redactArg));
|
||||
cons.error = (...args: unknown[]) => origError(...args.map(redactArg));
|
||||
cons.info = (...args: unknown[]) => origInfo(...args.map(redactArg));
|
||||
|
||||
const stdoutWriteOrig = (target?.stdoutWrite ?? process.stdout.write).bind(process.stdout);
|
||||
const stderrWriteOrig = (target?.stderrWrite ?? process.stderr.write).bind(process.stderr);
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
if (typeof chunk === "string") return stdoutWriteOrig(redactTokens(chunk), ...rest as never[]);
|
||||
return stdoutWriteOrig(chunk as never, ...rest as never[]);
|
||||
}) as typeof process.stdout.write;
|
||||
process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
if (typeof chunk === "string") return stderrWriteOrig(redactTokens(chunk), ...rest as never[]);
|
||||
return stderrWriteOrig(chunk as never, ...rest as never[]);
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
return () => {
|
||||
cons.log = origLog;
|
||||
cons.warn = origWarn;
|
||||
cons.error = origError;
|
||||
cons.info = origInfo;
|
||||
process.stdout.write = stdoutWriteOrig;
|
||||
process.stderr.write = stderrWriteOrig;
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function SchedulePicker({
|
||||
onScheduledAtChange: (value: string) => void
|
||||
onClearError: () => void
|
||||
}) {
|
||||
const minDatetime = useMemo(computeMinDatetime, [])
|
||||
const minDatetime = useMemo(() => computeMinDatetime(), [])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight } from "@phosphor-icons/react";
|
||||
import { ArrowRight } from "./icons";
|
||||
|
||||
const ATTENTION_STAGGER_MAX = 8;
|
||||
const ATTENTION_STAGGER_STEP = 0.04;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Trash, XCircle } from "@phosphor-icons/react";
|
||||
import { Trash, XCircle } from "./icons";
|
||||
import { ActionButton } from "./primitives";
|
||||
import { useFocusTrap } from "./hooks";
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* The renderer-bound `agent-event` push payload.
|
||||
*
|
||||
* The `event` payload is pi's AgentEvent (discriminated by `type`). We do not
|
||||
* mirror pi's type system in Zod (it would couple the renderer to pi's
|
||||
* internals); we validate the envelope and pass the event through opaquely.
|
||||
*/
|
||||
export const agentEventPushSchema = z.object({
|
||||
runId: z.string(),
|
||||
agentId: z.string(),
|
||||
companyId: z.string(),
|
||||
taskId: z.string().nullable(),
|
||||
emittedAt: z.string(),
|
||||
event: z.object({
|
||||
type: z.string(),
|
||||
}).passthrough(),
|
||||
});
|
||||
|
||||
export type AgentEventPush = z.infer<typeof agentEventPushSchema>;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const credentialKindSchema = z.enum([
|
||||
"oauth",
|
||||
"api_key",
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp_adc",
|
||||
]);
|
||||
|
||||
export const credentialStatusSchema = z.enum([
|
||||
"active",
|
||||
"expired",
|
||||
"revoked",
|
||||
"error",
|
||||
"not_configured",
|
||||
]);
|
||||
|
||||
export const apiKeyValueSchema = z.object({
|
||||
source: z.enum(["literal", "env"]),
|
||||
value: z.string().min(1),
|
||||
});
|
||||
|
||||
export const azureCredentialSchema = z.object({
|
||||
apiKey: z.string().min(1),
|
||||
baseUrl: z.string().url().optional().or(z.literal("")),
|
||||
resourceName: z.string().optional().or(z.literal("")),
|
||||
apiVersion: z.string().optional().or(z.literal("")),
|
||||
deploymentMap: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const awsCredentialSchema = z.object({
|
||||
authMode: z.enum(["profile", "iam", "bearer"]),
|
||||
region: z.string().min(1).default("us-east-1"),
|
||||
awsProfile: z.string().optional(),
|
||||
accessKeyId: z.string().optional(),
|
||||
secretAccessKey: z.string().optional(),
|
||||
sessionToken: z.string().optional(),
|
||||
bearerToken: z.string().optional(),
|
||||
forceCache: z.boolean().optional(),
|
||||
endpointUrl: z.string().optional(),
|
||||
skipAuth: z.boolean().optional(),
|
||||
forceHttp1: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const gcpAdcCredentialSchema = z.object({
|
||||
project: z.string().min(1),
|
||||
location: z.string().min(1),
|
||||
credentialsPath: z.string().optional(),
|
||||
});
|
||||
|
||||
export const credentialPayloadSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("api_key"), value: apiKeyValueSchema }),
|
||||
z.object({ kind: z.literal("azure"), value: azureCredentialSchema }),
|
||||
z.object({ kind: z.literal("aws"), value: awsCredentialSchema }),
|
||||
z.object({ kind: z.literal("gcp_adc"), value: gcpAdcCredentialSchema }),
|
||||
]);
|
||||
|
||||
export const saveCredentialInputSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
payload: credentialPayloadSchema,
|
||||
});
|
||||
|
||||
export const removeCredentialInputSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const testCredentialInputSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const credentialSummarySchema = z.object({
|
||||
connectorId: z.string(),
|
||||
kind: credentialKindSchema,
|
||||
displayHint: z.string(),
|
||||
status: credentialStatusSchema,
|
||||
expiresAt: z.string().nullable(),
|
||||
lastRefreshedAt: z.string().nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type CredentialSummary = z.infer<typeof credentialSummarySchema>;
|
||||
export type CredentialPayload = z.infer<typeof credentialPayloadSchema>;
|
||||
export type SaveCredentialInput = z.infer<typeof saveCredentialInputSchema>;
|
||||
export type AwsCredential = z.infer<typeof awsCredentialSchema>;
|
||||
export type AzureCredential = z.infer<typeof azureCredentialSchema>;
|
||||
export type GcpAdcCredential = z.infer<typeof gcpAdcCredentialSchema>;
|
||||
export type ApiKeyValue = z.infer<typeof apiKeyValueSchema>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const oauthStartInputSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const oauthCancelInputSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const oauthPromptResponseSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
requestId: z.string().min(1),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const oauthManualCodeResponseSchema = z.object({
|
||||
connectorId: z.string().min(1),
|
||||
requestId: z.string().min(1),
|
||||
code: z.string().min(1),
|
||||
});
|
||||
|
||||
export const oauthAuthInfoSchema = z.object({
|
||||
connectorId: z.string(),
|
||||
url: z.string().url(),
|
||||
instructions: z.string().optional(),
|
||||
});
|
||||
|
||||
export const oauthProgressSchema = z.object({
|
||||
connectorId: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export const oauthStatusSchema = z.object({
|
||||
connectorId: z.string(),
|
||||
status: z.enum(["active", "cancelled", "error", "in_progress"]),
|
||||
errorMessage: z.string().optional(),
|
||||
});
|
||||
|
||||
export const oauthPromptRequestSchema = z.object({
|
||||
connectorId: z.string(),
|
||||
requestId: z.string(),
|
||||
message: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
allowEmpty: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const oauthManualCodeRequestSchema = z.object({
|
||||
connectorId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export type OAuthStartInput = z.infer<typeof oauthStartInputSchema>;
|
||||
export type OAuthAuthInfoEvent = z.infer<typeof oauthAuthInfoSchema>;
|
||||
export type OAuthProgressEvent = z.infer<typeof oauthProgressSchema>;
|
||||
export type OAuthStatusEvent = z.infer<typeof oauthStatusSchema>;
|
||||
export type OAuthPromptRequestEvent = z.infer<typeof oauthPromptRequestSchema>;
|
||||
export type OAuthManualCodeRequestEvent = z.infer<typeof oauthManualCodeRequestSchema>;
|
||||
export type OAuthPromptResponseInput = z.infer<typeof oauthPromptResponseSchema>;
|
||||
export type OAuthManualCodeResponseInput = z.infer<typeof oauthManualCodeResponseSchema>;
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceState,
|
||||
DepartmentId,
|
||||
AgentStatus,
|
||||
AgentRoleLevel,
|
||||
GoalStatus,
|
||||
ProjectStatus,
|
||||
TaskStatus,
|
||||
@@ -73,6 +74,8 @@ export interface AgentRecord {
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
/** Seniority enum used for leadership detection; free-text `role` remains UI-editable. */
|
||||
roleLevel: AgentRoleLevel;
|
||||
department: DepartmentId | null;
|
||||
status: AgentStatus;
|
||||
reportsTo: string | null;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("agent model switching", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -98,7 +98,7 @@ describe("agent model switching", () => {
|
||||
title: "Frontend Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Build frontend features",
|
||||
@@ -147,7 +147,7 @@ describe("agent model switching", () => {
|
||||
title: "Frontend Engineer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.3-codex",
|
||||
capabilities: "Build frontend features",
|
||||
|
||||
@@ -25,7 +25,7 @@ async function createHarness() {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -52,7 +52,7 @@ async function createHarness() {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -65,7 +65,7 @@ async function createHarness() {
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -81,7 +81,7 @@ async function createHarness() {
|
||||
priority: "high",
|
||||
status: "in_progress",
|
||||
});
|
||||
const run = db.createRun({ companyId, taskId, agentId: ctoId, workspaceId, connectorId: "codex_local" });
|
||||
const run = db.createRun({ companyId, taskId, agentId: ctoId, workspaceId, connectorId: "openai-codex" });
|
||||
const token = createAgentToken(ctoId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
const port = await server.start();
|
||||
@@ -114,7 +114,7 @@ describe("recursive org execution", () => {
|
||||
role: "Engineer",
|
||||
title: "Frontend Engineer",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "React, TypeScript, Tailwind, frontend delivery",
|
||||
@@ -176,7 +176,7 @@ describe("recursive org execution", () => {
|
||||
title: "Director of People",
|
||||
status: "active",
|
||||
reportsTo: harness.ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Coordinate staffing",
|
||||
@@ -197,7 +197,7 @@ describe("recursive org execution", () => {
|
||||
taskId: hrTaskId,
|
||||
agentId: hrId,
|
||||
workspaceId: harness.workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const hrToken = createAgentToken(hrId, harness.companyId, hrRun.id);
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("recursive org execution", () => {
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer Golang",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Go, PostgreSQL, distributed systems",
|
||||
@@ -243,7 +243,7 @@ describe("recursive org execution", () => {
|
||||
role: "Backend Engineer",
|
||||
title: "Backend Engineer Golang",
|
||||
department: "engineering",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Go, APIs",
|
||||
@@ -270,7 +270,7 @@ describe("recursive org execution", () => {
|
||||
title: "Chief Marketing Officer",
|
||||
status: "active",
|
||||
reportsTo: harness.ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
@@ -287,7 +287,7 @@ describe("recursive org execution", () => {
|
||||
title: "Misrouted Engineer",
|
||||
department: "engineering",
|
||||
reportsTo: cmoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: harness.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Should not report outside the CTO branch",
|
||||
|
||||
@@ -40,7 +40,7 @@ async function createHarness() {
|
||||
title: "Lead agent",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Operate the company",
|
||||
@@ -55,7 +55,7 @@ async function createHarness() {
|
||||
priority: "high",
|
||||
status: "todo",
|
||||
});
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "codex_local" });
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "openai-codex" });
|
||||
const token = createAgentToken(agentId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
const port = await server.start();
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("Step 4: Hire approval + decideApproval", () => {
|
||||
type: "hire_agent",
|
||||
payloadSummary: "Hire CTO",
|
||||
impactSummary: "Test",
|
||||
payloadJson: JSON.stringify({ agentId: hireId, config: { connector: "codex_local" } }),
|
||||
payloadJson: JSON.stringify({ agentId: hireId, config: { connector: "openai-codex" } }),
|
||||
});
|
||||
|
||||
const approval = shared.db.getApprovalRecord(approvalId);
|
||||
|
||||
@@ -91,13 +91,13 @@ describe("Agent Messages", () => {
|
||||
beforeEach(() => {
|
||||
agentAId = shared.db.saveAgent({
|
||||
companyId: shared.companyId, name: "Alice", role: "Engineer", title: "Senior Engineer",
|
||||
department: "engineering", connectorId: "claude_local", status: "active",
|
||||
department: "engineering", connectorId: "claude-pro", status: "active",
|
||||
capabilities: "Backend development, API design, code review",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
agentBId = shared.db.saveAgent({
|
||||
companyId: shared.companyId, name: "Bob", role: "Designer", title: "UX Designer",
|
||||
department: "design", connectorId: "claude_local", status: "active",
|
||||
department: "design", connectorId: "claude-pro", status: "active",
|
||||
capabilities: "UI design, wireframing, user research",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
@@ -242,13 +242,13 @@ describe("Auto Task Assignment", () => {
|
||||
beforeEach(() => {
|
||||
backendId = shared.db.saveAgent({
|
||||
companyId: shared.companyId, name: "Backend Dev", role: "Backend Engineer", title: "Senior Backend",
|
||||
department: "engineering", connectorId: "codex_local", status: "active",
|
||||
department: "engineering", connectorId: "openai-codex", status: "active",
|
||||
capabilities: "API development, database design, Node.js, PostgreSQL, backend testing",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
frontendId = shared.db.saveAgent({
|
||||
companyId: shared.companyId, name: "Frontend Dev", role: "Frontend Engineer", title: "Senior Frontend",
|
||||
department: "engineering", connectorId: "codex_local", status: "active",
|
||||
department: "engineering", connectorId: "openai-codex", status: "active",
|
||||
capabilities: "React, TypeScript, CSS, Tailwind, responsive design, frontend testing, UI components",
|
||||
budgetMonthlyUsd: 200,
|
||||
});
|
||||
@@ -290,8 +290,8 @@ describe("Auto Task Assignment", () => {
|
||||
|
||||
it("returns null if all agents have exhausted their budget", () => {
|
||||
// Set positive budgets that are fully spent
|
||||
shared.db.saveAgent({ id: backendId, companyId: shared.companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "codex_local", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 });
|
||||
shared.db.saveAgent({ id: frontendId, companyId: shared.companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "codex_local", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 });
|
||||
shared.db.saveAgent({ id: backendId, companyId: shared.companyId, name: "Backend Dev", role: "Backend", title: "Backend", connectorId: "openai-codex", status: "active", capabilities: "backend", budgetMonthlyUsd: 100 });
|
||||
shared.db.saveAgent({ id: frontendId, companyId: shared.companyId, name: "Frontend Dev", role: "Frontend", title: "Frontend", connectorId: "openai-codex", status: "active", capabilities: "frontend", budgetMonthlyUsd: 100 });
|
||||
// Exhaust budgets by recording spending via direct SQL (since finishRun would need a real run)
|
||||
(shared.db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(backendId);
|
||||
(shared.db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db.prepare("update agents set spent_monthly_usd = 100 where id = ?").run(frontendId);
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("Step 7: Worker crash recovery (database layer)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId: shared.workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
shared.db.markRunStatus(run.id, "running", "Started");
|
||||
@@ -66,7 +66,7 @@ describe("Step 7: Worker crash recovery (database layer)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId: shared.workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
shared.db.markRunStatus(run.id, "running", "Started");
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("Step 6: Run failure escalation (database layer)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId: shared.workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
shared.db.markRunStatus(run.id, "running", "Started");
|
||||
|
||||
@@ -88,7 +88,7 @@ export function installSharedSetup(): SharedHandle {
|
||||
title: "Test",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: state.workspaceId,
|
||||
model: null,
|
||||
capabilities: "Test capabilities",
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("connector model enforcement", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -97,7 +97,7 @@ describe("connector model enforcement", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Lead the company",
|
||||
@@ -113,7 +113,7 @@ describe("connector model enforcement", () => {
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const { taskRunner } = createOrchestrationHarness(db);
|
||||
const result = taskRunner.queueTaskRun(taskId, "manual", "board");
|
||||
@@ -134,7 +134,7 @@ describe("connector model enforcement", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -161,7 +161,7 @@ describe("connector model enforcement", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
capabilities: "Lead the company",
|
||||
@@ -182,12 +182,12 @@ describe("connector model enforcement", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const checkoutSuccess = db.checkoutTask(taskId, run.id);
|
||||
expect(checkoutSuccess).toBe(true);
|
||||
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const { taskRunner } = createOrchestrationHarness(db);
|
||||
taskRunner.resumeQueuedRuns();
|
||||
|
||||
@@ -52,10 +52,10 @@ const mockLogger = {
|
||||
|
||||
/**
|
||||
* Creates a full entity scaffold with a budget policy:
|
||||
* company, workspace, connector (gemini_local), agent, goal, project, budget policy.
|
||||
* company, workspace, connector (gemini-cli), agent, goal, project, budget policy.
|
||||
*/
|
||||
function scaffold(db: AppDatabase, budgetAmountCents = 500): ScaffoldResult {
|
||||
const connectorId: ConnectorId = "gemini_local";
|
||||
const connectorId: ConnectorId = "gemini-cli";
|
||||
|
||||
db.saveConnector({
|
||||
id: connectorId,
|
||||
@@ -228,7 +228,7 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -387,7 +387,7 @@ describe("Cross-Subsystem Pipeline (D-04)", () => {
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -45,7 +59,7 @@ describe("AppDatabase", () => {
|
||||
title: "Lead agent",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Operate the company",
|
||||
@@ -89,7 +103,7 @@ describe("AppDatabase", () => {
|
||||
});
|
||||
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: null,
|
||||
envBindingText: "OPENAI_API_KEY=vault-openai",
|
||||
@@ -105,7 +119,7 @@ describe("AppDatabase", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
await db.appendRunLog(run.id, "stdout", "hello from run\n");
|
||||
@@ -190,7 +204,7 @@ describe("AppDatabase", () => {
|
||||
title: "",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: alphaWorkspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead alpha",
|
||||
@@ -204,7 +218,7 @@ describe("AppDatabase", () => {
|
||||
title: "",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: betaWorkspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead beta",
|
||||
|
||||
@@ -26,7 +26,7 @@ async function createHarness() {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -53,7 +53,7 @@ async function createHarness() {
|
||||
title: "Research Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Research and synthesis",
|
||||
@@ -86,7 +86,7 @@ async function createHarness() {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const token = createAgentToken(agentId, companyId, run.id);
|
||||
const server = createApiServer(db, () => {}, () => null);
|
||||
|
||||
@@ -6,6 +6,16 @@ import { AppDatabase } from "../../src/main/database";
|
||||
import { createOnboardingService } from "../../src/main/onboarding-service";
|
||||
import { buildRunPayload } from "../../src/main/orchestration/turn-context";
|
||||
import { getConnectorDefinition } from "../../src/main/connectors";
|
||||
import { getModels } from "@mariozechner/pi-ai";
|
||||
|
||||
function listProviderModels(provider: string): Array<{ id: string; label: string }> {
|
||||
try {
|
||||
const models = getModels(provider as never);
|
||||
return models.map((model) => ({ id: model.id, label: model.name ?? model.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
@@ -80,22 +90,22 @@ describe("onboarding model pool integration", () => {
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const enabledModel = codexDefinition.module.models?.[0]?.id;
|
||||
const disabledModel = codexDefinition.module.models?.[1]?.id;
|
||||
const codexDefinition = getConnectorDefinition("openai-codex");
|
||||
const enabledModel = listProviderModels(codexDefinition.provider)[0]?.id;
|
||||
const disabledModel = listProviderModels(codexDefinition.provider)[1]?.id;
|
||||
|
||||
if (!enabledModel || !disabledModel) {
|
||||
throw new Error("codex_local requires at least two models for this integration test.");
|
||||
throw new Error("openai-codex requires at least two models for this integration test.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: disabledModel, label: disabledModel }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: disabledModel, label: disabledModel }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Model Governance Co",
|
||||
@@ -106,7 +116,7 @@ describe("onboarding model pool integration", () => {
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch runtime-safe company",
|
||||
goalDescription: "Ensure onboarding connector models flow into runtime decisions.",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
connectorModel: enabledModel,
|
||||
});
|
||||
|
||||
@@ -115,9 +125,9 @@ describe("onboarding model pool integration", () => {
|
||||
}
|
||||
|
||||
const agent = db.getAgent(summary.leadAgentId);
|
||||
expect(agent.connectorId).toBe("codex_local");
|
||||
expect(agent.connectorId).toBe("openai-codex");
|
||||
expect(agent.model).toBe(enabledModel);
|
||||
expect(db.getDisabledModels("codex_local")).toEqual([{ id: disabledModel, label: disabledModel }]);
|
||||
expect(db.getDisabledModels("openai-codex")).toEqual([{ id: disabledModel, label: disabledModel }]);
|
||||
expect(db.getCurrentCompanyId()).toBe(summary.companyId);
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
@@ -128,7 +138,7 @@ describe("onboarding model pool integration", () => {
|
||||
expect(systemPrompt).toContain(enabledModel);
|
||||
const codexConnectorLine = systemPrompt
|
||||
.split("\n")
|
||||
.find((line) => line.includes("**Codex**"));
|
||||
.find((line) => line.includes("ChatGPT Plus/Pro (Codex)"));
|
||||
expect(codexConnectorLine).toBeDefined();
|
||||
expect(codexConnectorLine).not.toContain(`, ${disabledModel},`);
|
||||
expect(codexConnectorLine).not.toContain(` ${disabledModel}.`);
|
||||
@@ -136,46 +146,53 @@ describe("onboarding model pool integration", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("uses compat connector csv models minus disabled entries in runtime prompt context", async () => {
|
||||
it("excludes disabled pi-ai models from anthropic connector runtime prompt context", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-onboarding-compat-");
|
||||
const workspaceDir = await createTempDir("agentcompany-onboarding-compat-workspace-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
db.saveConnector({
|
||||
id: "claude_compatible",
|
||||
command: "claude",
|
||||
model: "model-alpha,model-beta,model-gamma",
|
||||
envBindingText: "",
|
||||
notes: "Custom provider",
|
||||
});
|
||||
const anthropicDefinition = getConnectorDefinition("anthropic");
|
||||
const anthropicModels = listProviderModels(anthropicDefinition.provider);
|
||||
const enabledModel = anthropicModels[0]?.id;
|
||||
const disabledModel = anthropicModels[1]?.id;
|
||||
if (!enabledModel || !disabledModel) {
|
||||
throw new Error("anthropic provider must expose at least two models from pi-ai.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "claude_compatible",
|
||||
id: "anthropic",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("claude_compatible", [{ id: "model-beta", label: "model-beta" }]);
|
||||
db.setDisabledModels("anthropic", [{ id: disabledModel, label: disabledModel }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Compat Governance Co",
|
||||
companyDescription: "Tests compat model governance",
|
||||
companyName: "Anthropic Governance Co",
|
||||
companyDescription: "Tests anthropic model governance",
|
||||
workspacePath: workspaceDir,
|
||||
autoApproveHires: true,
|
||||
reviewDeliverables: false,
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch compat company",
|
||||
goalDescription: "Ensure compat model pools survive onboarding and runtime prompt assembly.",
|
||||
connectorId: "claude_compatible",
|
||||
connectorModel: "model-alpha",
|
||||
goalTitle: "Launch anthropic company",
|
||||
goalDescription: "Ensure anthropic model pools survive onboarding and runtime prompt assembly.",
|
||||
connectorId: "anthropic",
|
||||
connectorModel: enabledModel,
|
||||
});
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
expect(payload.systemPrompt).not.toBeNull();
|
||||
const systemPrompt = payload.systemPrompt ?? "";
|
||||
expect(systemPrompt).toContain("Enabled models: model-alpha, model-gamma.");
|
||||
expect(systemPrompt).not.toContain("model-beta");
|
||||
expect(systemPrompt).toContain("Enabled models:");
|
||||
expect(systemPrompt).toContain(enabledModel);
|
||||
const anthropicLine = systemPrompt
|
||||
.split("\n")
|
||||
.find((line) => line.includes(anthropicDefinition.label));
|
||||
expect(anthropicLine).toBeDefined();
|
||||
expect(anthropicLine).not.toContain(`, ${disabledModel},`);
|
||||
expect(anthropicLine).not.toContain(` ${disabledModel}.`);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -187,7 +204,7 @@ describe("onboarding model pool integration", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -203,7 +220,7 @@ describe("onboarding model pool integration", () => {
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch default-mode company",
|
||||
goalDescription: "Leave the CEO model unset so runtime falls back to connector defaults.",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
connectorModel: "",
|
||||
});
|
||||
|
||||
@@ -215,7 +232,7 @@ describe("onboarding model pool integration", () => {
|
||||
expect(agent.model).toBeNull();
|
||||
|
||||
const payload = buildKickoffPayload(db, summary);
|
||||
const defaultModel = getConnectorDefinition("codex_local").defaultModel;
|
||||
const defaultModel = getConnectorDefinition("openai-codex").defaultModel;
|
||||
expect(payload.model).toBe(defaultModel);
|
||||
|
||||
db.close();
|
||||
@@ -227,22 +244,22 @@ describe("onboarding model pool integration", () => {
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const codexDefinition = getConnectorDefinition("openai-codex");
|
||||
const defaultModel = codexDefinition.defaultModel;
|
||||
const fallbackModel = codexDefinition.module.models?.find((model) => model.id !== defaultModel)?.id;
|
||||
const fallbackModel = listProviderModels(codexDefinition.provider).find((model) => model.id !== defaultModel)?.id;
|
||||
|
||||
if (!defaultModel || !fallbackModel) {
|
||||
throw new Error("codex_local requires a default and fallback model for reroute testing.");
|
||||
throw new Error("openai-codex requires a default and fallback model for reroute testing.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: defaultModel, label: defaultModel }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: defaultModel, label: defaultModel }]);
|
||||
|
||||
const summary = createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Default Reroute Co",
|
||||
@@ -253,7 +270,7 @@ describe("onboarding model pool integration", () => {
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Launch rerouted company",
|
||||
goalDescription: "Ensure runtime picks the first enabled model when connector default is disabled.",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
connectorModel: "",
|
||||
});
|
||||
|
||||
@@ -269,21 +286,21 @@ describe("onboarding model pool integration", () => {
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const codexDefinition = getConnectorDefinition("codex_local");
|
||||
const disabledModel = codexDefinition.module.models?.[0]?.id;
|
||||
const codexDefinition = getConnectorDefinition("openai-codex");
|
||||
const disabledModel = listProviderModels(codexDefinition.provider)[0]?.id;
|
||||
|
||||
if (!disabledModel) {
|
||||
throw new Error("codex_local requires a model to validate onboarding rejection.");
|
||||
throw new Error("openai-codex requires a model to validate onboarding rejection.");
|
||||
}
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: disabledModel, label: disabledModel }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: disabledModel, label: disabledModel }]);
|
||||
|
||||
expect(() => createBootstrapHarness(db).bootstrapOnboarding({
|
||||
companyName: "Rejected Model Co",
|
||||
@@ -294,9 +311,9 @@ describe("onboarding model pool integration", () => {
|
||||
executionMode: "ai_ceo",
|
||||
goalTitle: "Reject disabled model bootstrap",
|
||||
goalDescription: "Invalid explicit models should fail before the CEO is created.",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
connectorModel: disabledModel,
|
||||
})).toThrow(`Codex does not allow model "${disabledModel}".`);
|
||||
})).toThrow(`${codexDefinition.label} does not allow model "${disabledModel}".`);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
beforeAll(() => {
|
||||
// SecretVault gated for test contexts. Used by the credentials path
|
||||
// touched by migration 28's downstream consumers.
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function freshProfile(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
/**
|
||||
* Migration 27 (`session_entries`) and 28 (`credentials`) ship in the rebuild.
|
||||
* These tests verify both migrations:
|
||||
* - run cleanly on a fresh SQLite profile;
|
||||
* - are idempotent on subsequent `init()` calls (existing-user upgrade);
|
||||
* - leave their tables queryable.
|
||||
*/
|
||||
describe("pi-mono migrations 27 + 28", () => {
|
||||
it("creates session_entries on first init and accepts inserts", async () => {
|
||||
const dir = await freshProfile("agentcompany-migration-27-fresh-");
|
||||
const db = new AppDatabase(dir);
|
||||
db.init();
|
||||
|
||||
db.sessionEntries.append({
|
||||
taskSessionId: "test-session",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
payloadJson: JSON.stringify({ role: "user", content: "hello" }),
|
||||
});
|
||||
const rows = db.sessionEntries.listActive("test-session");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.kind).toBe("message");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("creates credentials on first init and accepts encrypted inserts", async () => {
|
||||
const dir = await freshProfile("agentcompany-migration-28-fresh-");
|
||||
const db = new AppDatabase(dir);
|
||||
db.init();
|
||||
|
||||
const cipher = db.vault.encrypt(JSON.stringify({ source: "literal", value: "sk-test-1234567890" }));
|
||||
db.credentialsTable.upsert({
|
||||
connectorId: "test-anthropic",
|
||||
kind: "api_key",
|
||||
backend: cipher.backend,
|
||||
ciphertext: cipher.payload,
|
||||
displayHint: "sk-test-...7890",
|
||||
status: "active",
|
||||
expiresAt: null,
|
||||
lastRefreshedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const rows = db.credentialsTable.list();
|
||||
expect(rows.some((row) => row.connectorId === "test-anthropic")).toBe(true);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("re-running init() on an existing profile does not re-apply migrations or duplicate rows", async () => {
|
||||
const dir = await freshProfile("agentcompany-migration-idempotent-");
|
||||
const dbA = new AppDatabase(dir);
|
||||
dbA.init();
|
||||
dbA.sessionEntries.append({
|
||||
taskSessionId: "session-x",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
payloadJson: JSON.stringify({ role: "user", content: "first" }),
|
||||
});
|
||||
const cipher = dbA.vault.encrypt(JSON.stringify({ source: "literal", value: "sk-keep-me" }));
|
||||
dbA.credentialsTable.upsert({
|
||||
connectorId: "test-keep",
|
||||
kind: "api_key",
|
||||
backend: cipher.backend,
|
||||
ciphertext: cipher.payload,
|
||||
displayHint: "sk-keep-...e",
|
||||
status: "active",
|
||||
expiresAt: null,
|
||||
lastRefreshedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
dbA.close();
|
||||
|
||||
const dbB = new AppDatabase(dir);
|
||||
dbB.init();
|
||||
expect(dbB.sessionEntries.listActive("session-x")).toHaveLength(1);
|
||||
expect(dbB.credentialsTable.list().filter((row) => row.connectorId === "test-keep")).toHaveLength(1);
|
||||
dbB.close();
|
||||
});
|
||||
|
||||
it("session_entries respects the unique (task_session_id, sequence) index across replace cycles", async () => {
|
||||
const dir = await freshProfile("agentcompany-migration-replace-");
|
||||
const db = new AppDatabase(dir);
|
||||
db.init();
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
db.sessionEntries.append({
|
||||
taskSessionId: "replace-test",
|
||||
kind: "message",
|
||||
role: "user",
|
||||
payloadJson: JSON.stringify({ role: "user", content: `t${i}` }),
|
||||
});
|
||||
}
|
||||
db.sessionEntries.replace({
|
||||
taskSessionId: "replace-test",
|
||||
entries: [
|
||||
{ kind: "compaction", role: null, payloadJson: JSON.stringify({ summary: "summarised" }) },
|
||||
{ kind: "message", role: "user", payloadJson: JSON.stringify({ role: "user", content: "post" }) },
|
||||
],
|
||||
});
|
||||
|
||||
const active = db.sessionEntries.listActive("replace-test");
|
||||
expect(active).toHaveLength(2);
|
||||
expect(active[0]?.kind).toBe("compaction");
|
||||
expect(active[1]?.kind).toBe("message");
|
||||
|
||||
const all = db.sessionEntries.listAll("replace-test");
|
||||
expect(all.length).toBe(5);
|
||||
expect(all.filter((row) => row.isSuperseded).length).toBe(3);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("seedConnectors prunes legacy connector rows and inserts the full pi-mono + Fireworks catalog", async () => {
|
||||
const dir = await freshProfile("agentcompany-migration-seed-");
|
||||
const db = new AppDatabase(dir);
|
||||
db.init();
|
||||
|
||||
const rows = db.listSnapshot().connectors.map((entry) => entry.id);
|
||||
expect(rows.length).toBe(26);
|
||||
expect(rows).toContain("claude-pro");
|
||||
expect(rows).toContain("amazon-bedrock");
|
||||
expect(rows).toContain("google-vertex");
|
||||
expect(rows).toContain("fireworks");
|
||||
expect(rows).toContain("fireworks-firepass");
|
||||
expect(rows.find((id) => (id as string) === "claude_local")).toBeUndefined();
|
||||
expect(rows.find((id) => (id as string) === "codex_local")).toBeUndefined();
|
||||
expect(rows.find((id) => (id as string) === "claude_compatible")).toBeUndefined();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ interface ScaffoldResult {
|
||||
|
||||
/** Creates a full entity scaffold: company, workspace, connector, agent, goal, project. */
|
||||
function scaffold(db: AppDatabase): ScaffoldResult {
|
||||
const connectorId: ConnectorId = "gemini_local";
|
||||
const connectorId: ConnectorId = "gemini-cli";
|
||||
|
||||
// Connector
|
||||
db.saveConnector({
|
||||
@@ -445,7 +445,7 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
taskId,
|
||||
companyId: s.companyId,
|
||||
agentId: s.agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -466,7 +466,7 @@ describe("Process Recovery -- Context Preservation (D-02)", () => {
|
||||
|
||||
const rotationResult = sessionService.evaluateRotation(
|
||||
session!,
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
{ maxRuns: 1 },
|
||||
);
|
||||
expect(rotationResult.shouldRotate).toBe(true);
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("skill-aware capability registry", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -53,7 +53,7 @@ describe("skill-aware capability registry", () => {
|
||||
title: "Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "General software delivery",
|
||||
@@ -66,7 +66,7 @@ describe("skill-aware capability registry", () => {
|
||||
title: "Frontend Specialist",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "General software delivery",
|
||||
@@ -116,7 +116,7 @@ describe("skill-aware capability registry", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -143,7 +143,7 @@ describe("skill-aware capability registry", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -193,7 +193,7 @@ describe("skill-aware capability registry", () => {
|
||||
recentComments: [],
|
||||
socialAccounts: [],
|
||||
allConnectors: snapshot.connectors
|
||||
.filter((connector) => connector.id === "codex_local")
|
||||
.filter((connector) => connector.id === "openai-codex")
|
||||
.map((connector) => ({ id: connector.id, label: connector.label, status: connector.status, enabledModels: ["gpt-5.4"] })),
|
||||
companySkillCatalog: db.listCompanySkills(companyId).map((skill) => ({
|
||||
name: skill.name,
|
||||
|
||||
@@ -29,7 +29,7 @@ function createNoopLogger() {
|
||||
|
||||
function createScaffold(db: AppDatabase) {
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -57,7 +57,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -70,7 +70,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Lead Engineer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
@@ -140,7 +140,7 @@ describe("thread wait + heartbeat integration", () => {
|
||||
title: "Chief Marketing Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: db.getTask(taskId).workspaceId ?? null,
|
||||
model: null,
|
||||
capabilities: "Drive marketing work",
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("agent budget policy synchronization", () => {
|
||||
status: "active",
|
||||
});
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -49,7 +49,7 @@ describe("agent budget policy synchronization", () => {
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
@@ -78,7 +78,7 @@ describe("agent budget policy synchronization", () => {
|
||||
title: "Backend Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build backend services",
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("hire routing guards", () => {
|
||||
status: "active",
|
||||
});
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -50,7 +50,7 @@ describe("hire routing guards", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -64,7 +64,7 @@ describe("hire routing guards", () => {
|
||||
title: "Head of HR",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Run hiring operations",
|
||||
@@ -78,7 +78,7 @@ describe("hire routing guards", () => {
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -64,7 +64,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -78,7 +78,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Software Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build features",
|
||||
@@ -120,7 +120,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -133,7 +133,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Operations Specialist",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Coordinate operations",
|
||||
@@ -174,7 +174,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -187,7 +187,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -201,7 +201,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Chief Marketing Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
@@ -215,7 +215,7 @@ describe("canManageOrganization", () => {
|
||||
title: "Frontend Engineer",
|
||||
status: "active",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build frontend features",
|
||||
|
||||
@@ -10,7 +10,7 @@ const baseHireRequest = {
|
||||
title: "Backend Engineer",
|
||||
department: "engineering" as const,
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local" as const,
|
||||
connectorId: "openai-codex" as const,
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("hire approval routing", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -51,7 +51,7 @@ describe("hire approval routing", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -64,7 +64,7 @@ describe("hire approval routing", () => {
|
||||
title: "Chief Technology Officer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -80,7 +80,7 @@ describe("hire approval routing", () => {
|
||||
title: "Frontend Engineer",
|
||||
department: "engineering",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
metadataJson: "{}",
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("HumanApprovalHook", () => {
|
||||
title: "Eng",
|
||||
status: "running",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Build",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("deliverable review feedback writeback", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -51,7 +51,7 @@ describe("deliverable review feedback writeback", () => {
|
||||
title: "Research Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Research and synthesis",
|
||||
|
||||
@@ -37,7 +37,7 @@ function createTestCompany(db: AppDatabase): string {
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
@@ -64,7 +64,7 @@ function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
@@ -115,7 +115,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
const result = db.checkoutTask(taskId, runId);
|
||||
@@ -137,14 +137,14 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const { id: runId2 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId1)).toBe(true);
|
||||
@@ -166,7 +166,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId)).toBe(false);
|
||||
@@ -184,7 +184,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
@@ -222,7 +222,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
@@ -251,7 +251,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
@@ -291,7 +291,7 @@ describe("CAS checkout", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("buildCapabilityRegistrySection", () => {
|
||||
{ name: "Primary", localPath: "/tmp/workspace" },
|
||||
],
|
||||
allConnectors: [
|
||||
{ id: "codex_local", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] },
|
||||
{ id: "openai-codex", label: "Codex", status: "ready", enabledModels: ["gpt-5.4"] },
|
||||
],
|
||||
companyDocuments: [
|
||||
{
|
||||
@@ -38,7 +38,7 @@ describe("buildCapabilityRegistrySection", () => {
|
||||
expect(section).toContain("# Company Capability Registry");
|
||||
expect(section).toContain("React Standards (`react-standards`)");
|
||||
expect(section).toContain("Primary: /tmp/workspace");
|
||||
expect(section).toContain("Codex (`codex_local`) — models: gpt-5.4");
|
||||
expect(section).toContain("Codex (`openai-codex`) — models: gpt-5.4");
|
||||
expect(section).toContain("architecture_decision");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWindowsSpawnTarget, normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { commandLooksLike } from "@agentcompany/adapter-utils/test-utils";
|
||||
import { normalizeCommandValue } from "@main/connector-helpers";
|
||||
|
||||
describe("command path normalization", () => {
|
||||
it("removes surrounding quotes from absolute executable paths", () => {
|
||||
expect(normalizeCommandValue('"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe"')).toBe(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
);
|
||||
expect(normalizeCommandValue("'C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe'")).toBe(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
);
|
||||
it("trims surrounding whitespace from command values", () => {
|
||||
expect(normalizeCommandValue(" claude ")).toBe("claude");
|
||||
expect(normalizeCommandValue("\tcodex\n")).toBe("codex");
|
||||
});
|
||||
|
||||
it("keeps bare commands unchanged", () => {
|
||||
@@ -17,31 +12,8 @@ describe("command path normalization", () => {
|
||||
expect(normalizeCommandValue("codex")).toBe("codex");
|
||||
});
|
||||
|
||||
it("matches quoted commands against connector executables", () => {
|
||||
expect(commandLooksLike('"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe"', "claude")).toBe(true);
|
||||
expect(commandLooksLike("'C:\\Users\\First Last\\AppData\\Local\\bin\\codex.exe'", "codex")).toBe(true);
|
||||
});
|
||||
|
||||
it("spawns direct exe paths on Windows instead of wrapping them through cmd.exe", () => {
|
||||
const target = buildWindowsSpawnTarget(
|
||||
"C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
["--version"],
|
||||
"cmd.exe",
|
||||
);
|
||||
expect(target).toEqual({
|
||||
command: "C:\\Users\\First Last\\AppData\\Local\\bin\\claude.exe",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
it("still wraps shell scripts through cmd.exe on Windows", () => {
|
||||
const target = buildWindowsSpawnTarget(
|
||||
"C:\\Tools\\agentcompany\\launch.cmd",
|
||||
["--version"],
|
||||
"cmd.exe",
|
||||
);
|
||||
expect(target.command).toBe("cmd.exe");
|
||||
expect(target.args[0]).toBe("/d");
|
||||
expect(target.args.at(-1)).toContain("launch.cmd");
|
||||
it("normalizes null and undefined to empty string", () => {
|
||||
expect(normalizeCommandValue(null)).toBe("");
|
||||
expect(normalizeCommandValue(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ describe("company mode switching", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -63,7 +63,7 @@ describe("company mode switching", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -76,7 +76,7 @@ describe("company mode switching", () => {
|
||||
title: "Lead Engineer",
|
||||
status: "active",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ship features",
|
||||
|
||||
@@ -3,38 +3,37 @@ import {
|
||||
inferTaskType,
|
||||
getConnectorStrengths,
|
||||
TASK_TYPE_PREFERENCE,
|
||||
CONNECTOR_STRENGTHS as _CONNECTOR_STRENGTHS,
|
||||
ROLE_DEPARTMENT_HINTS as _ROLE_DEPARTMENT_HINTS,
|
||||
} from "../../src/main/connector-matching";
|
||||
|
||||
describe("connector-matching", () => {
|
||||
describe("TASK_TYPE_PREFERENCE", () => {
|
||||
it("maps code tasks to claude_local first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.code[0]).toBe("claude_local");
|
||||
it("maps code tasks to claude-pro first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.code[0]).toBe("claude-pro");
|
||||
});
|
||||
|
||||
it("maps research tasks to gemini_local first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.research[0]).toBe("gemini_local");
|
||||
it("maps research tasks to google first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.research[0]).toBe("google");
|
||||
});
|
||||
|
||||
it("maps content tasks to claude_local first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.content[0]).toBe("claude_local");
|
||||
it("maps content tasks to claude-pro first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.content[0]).toBe("claude-pro");
|
||||
});
|
||||
|
||||
it("maps management tasks to claude_local first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.management[0]).toBe("claude_local");
|
||||
it("maps management tasks to claude-pro first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.management[0]).toBe("claude-pro");
|
||||
});
|
||||
|
||||
it("maps general tasks to claude_local first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.general[0]).toBe("claude_local");
|
||||
it("maps general tasks to claude-pro first", () => {
|
||||
expect(TASK_TYPE_PREFERENCE.general[0]).toBe("claude-pro");
|
||||
});
|
||||
|
||||
it("includes all three core connectors for every task type", () => {
|
||||
for (const key of Object.keys(TASK_TYPE_PREFERENCE)) {
|
||||
const prefs = TASK_TYPE_PREFERENCE[key as keyof typeof TASK_TYPE_PREFERENCE];
|
||||
expect(prefs).toContain("claude_local");
|
||||
expect(prefs).toContain("codex_local");
|
||||
expect(prefs).toContain("gemini_local");
|
||||
expect(prefs).toContain("claude-pro");
|
||||
expect(prefs).toContain("openai-codex");
|
||||
expect(prefs).toContain("gemini-cli");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,22 +101,22 @@ describe("connector-matching", () => {
|
||||
});
|
||||
|
||||
describe("getConnectorStrengths", () => {
|
||||
it("returns non-empty string for claude_local", () => {
|
||||
const result = getConnectorStrengths("claude_local");
|
||||
it("returns non-empty string for claude-pro", () => {
|
||||
const result = getConnectorStrengths("claude-pro");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("returns non-empty string for codex_local", () => {
|
||||
const result = getConnectorStrengths("codex_local");
|
||||
it("returns non-empty string for openai-codex", () => {
|
||||
const result = getConnectorStrengths("openai-codex");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("returns non-empty string for gemini_local", () => {
|
||||
const result = getConnectorStrengths("gemini_local");
|
||||
it("returns non-empty string for gemini-cli", () => {
|
||||
const result = getConnectorStrengths("gemini-cli");
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
|
||||
@@ -8,72 +8,85 @@ function createDeps(overrides: {
|
||||
} = {}) {
|
||||
return {
|
||||
getConnector: () => ({
|
||||
id: "claude_compatible" as const,
|
||||
label: "Claude Compatible",
|
||||
model: overrides.connectorModel ?? "model-alpha,model-beta,model-gamma",
|
||||
id: "anthropic" as const,
|
||||
label: "Anthropic",
|
||||
model: overrides.connectorModel ?? null,
|
||||
}),
|
||||
getDisabledModels: () => overrides.disabledModels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("connector model policy", () => {
|
||||
it("derives enabled compat models from connector config minus disabled entries", () => {
|
||||
const policy = getConnectorModelPolicy(
|
||||
createDeps({ disabledModels: [{ id: "model-beta", label: "model-beta" }] }),
|
||||
"claude_compatible",
|
||||
it("excludes disabled models from the enabled set", () => {
|
||||
const policy = getConnectorModelPolicy(createDeps(), "anthropic");
|
||||
const allIds = policy.enabledModels.map((model) => model.id);
|
||||
if (allIds.length === 0) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
|
||||
const firstModel = allIds[0];
|
||||
const filteredPolicy = getConnectorModelPolicy(
|
||||
createDeps({ disabledModels: [{ id: firstModel, label: firstModel }] }),
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
expect(policy.enabledModels.map((model) => model.id)).toEqual(["model-alpha", "model-gamma"]);
|
||||
expect(filteredPolicy.enabledModels.some((model) => model.id === firstModel)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicit enabled model", () => {
|
||||
const result = validateConnectorModelSelection(createDeps(), "claude_compatible", "model-alpha");
|
||||
const policy = getConnectorModelPolicy(createDeps(), "anthropic");
|
||||
const enabledId = policy.enabledModels[0]?.id;
|
||||
if (!enabledId) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
const result = validateConnectorModelSelection(createDeps(), "anthropic", enabledId);
|
||||
|
||||
expect(result).toEqual({ ok: true, effectiveModel: "model-alpha" });
|
||||
expect(result).toEqual({ ok: true, effectiveModel: enabledId });
|
||||
});
|
||||
|
||||
it("rejects an explicit disabled model", () => {
|
||||
const policy = getConnectorModelPolicy(createDeps(), "anthropic");
|
||||
const enabledId = policy.enabledModels[0]?.id;
|
||||
if (!enabledId) {
|
||||
throw new Error("anthropic provider should expose at least one model from pi-ai.");
|
||||
}
|
||||
const result = validateConnectorModelSelection(
|
||||
createDeps({ disabledModels: [{ id: "model-beta", label: "model-beta" }] }),
|
||||
"claude_compatible",
|
||||
"model-beta",
|
||||
createDeps({ disabledModels: [{ id: enabledId, label: enabledId }] }),
|
||||
"anthropic",
|
||||
enabledId,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected explicit disabled model to be rejected.");
|
||||
}
|
||||
expect(result.error).toContain('does not allow model "model-beta"');
|
||||
expect(result.error).toContain(`does not allow model "${enabledId}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native connector defaults", () => {
|
||||
it("uses the persisted connector model as the native default when configured", () => {
|
||||
const codexModels = getConnectorDefinition("codex_local").module.models ?? [];
|
||||
const configuredDefault = codexModels[1]?.id;
|
||||
|
||||
if (!configuredDefault) {
|
||||
throw new Error("codex_local requires at least two models for this default policy test.");
|
||||
}
|
||||
it("falls back to definition defaultModel when connector model is empty", () => {
|
||||
const definition = getConnectorDefinition("openai-codex");
|
||||
const configuredDefault = definition.defaultModel;
|
||||
|
||||
const policy = getConnectorModelPolicy({
|
||||
getConnector: () => ({
|
||||
id: "codex_local" as const,
|
||||
id: "openai-codex" as const,
|
||||
label: "Codex",
|
||||
model: configuredDefault,
|
||||
model: null,
|
||||
}),
|
||||
getDisabledModels: () => [],
|
||||
}, "codex_local");
|
||||
}, "openai-codex");
|
||||
|
||||
expect(policy.defaultModel).toBe(configuredDefault);
|
||||
expect(validateConnectorModelSelection({
|
||||
getConnector: () => ({
|
||||
id: "codex_local" as const,
|
||||
id: "openai-codex" as const,
|
||||
label: "Codex",
|
||||
model: configuredDefault,
|
||||
model: null,
|
||||
}),
|
||||
getDisabledModels: () => [],
|
||||
}, "codex_local", null)).toEqual({
|
||||
}, "openai-codex", null)).toEqual({
|
||||
ok: true,
|
||||
effectiveModel: configuredDefault,
|
||||
});
|
||||
|
||||
@@ -19,16 +19,20 @@ describe("parseEnvBindingText", () => {
|
||||
|
||||
describe("buildDefaultConnectorRows", () => {
|
||||
it("preserves previously saved connector commands", () => {
|
||||
const [codex] = buildDefaultConnectorRows([
|
||||
const rows = buildDefaultConnectorRows([
|
||||
{
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "/usr/local/bin/codex",
|
||||
status: "ready",
|
||||
notes: "custom note",
|
||||
},
|
||||
]);
|
||||
const codex = rows.find((row) => row.id === "openai-codex");
|
||||
if (!codex) {
|
||||
throw new Error("openai-codex connector not found in default rows.");
|
||||
}
|
||||
|
||||
expect(codex.id).toBe("codex_local");
|
||||
expect(codex.id).toBe("openai-codex");
|
||||
expect(codex.command).toBe("/usr/local/bin/codex");
|
||||
expect(codex.status).toBe("ready");
|
||||
expect(codex.notes).toBe("custom note");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
describe("shared contracts", () => {
|
||||
it("fills connector defaults for optional fields", () => {
|
||||
const parsed = connectorInputSchema.parse({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
import { CredentialResolver, CredentialError } from "../../src/main/credentials/credential-resolver";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
describe("CredentialResolver", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
let resolver: CredentialResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-credential-resolver-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
resolver = new CredentialResolver(store);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns the literal apiKey for an api_key connector", async () => {
|
||||
store.save("anthropic", {
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: "sk-ant-test-1234567890abcd" },
|
||||
});
|
||||
const resolved = await resolver.resolve("anthropic");
|
||||
expect(resolved.apiKey).toBe("sk-ant-test-1234567890abcd");
|
||||
expect(resolved.extraEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves an api_key connector backed by an env variable", async () => {
|
||||
process.env.MY_TEST_OPENAI_KEY = "sk-from-env-1234";
|
||||
store.save("openai", {
|
||||
kind: "api_key",
|
||||
value: { source: "env", value: "MY_TEST_OPENAI_KEY" },
|
||||
});
|
||||
try {
|
||||
const resolved = await resolver.resolve("openai");
|
||||
expect(resolved.apiKey).toBe("sk-from-env-1234");
|
||||
} finally {
|
||||
delete process.env.MY_TEST_OPENAI_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws CREDENTIAL_MISSING when the env-backed key is unset", async () => {
|
||||
delete process.env.UNSET_TEST_KEY;
|
||||
store.save("xai", { kind: "api_key", value: { source: "env", value: "UNSET_TEST_KEY" } });
|
||||
await expect(resolver.resolve("xai")).rejects.toBeInstanceOf(CredentialError);
|
||||
});
|
||||
|
||||
it("assembles AWS extraEnv for IAM auth mode", async () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "iam",
|
||||
region: "us-west-2",
|
||||
accessKeyId: "AKIA-TEST-FAKE-1234",
|
||||
secretAccessKey: "secret-fake-secret",
|
||||
sessionToken: "session-fake-token",
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("amazon-bedrock");
|
||||
expect(resolved.apiKey).toBe("");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
AWS_REGION: "us-west-2",
|
||||
AWS_ACCESS_KEY_ID: "AKIA-TEST-FAKE-1234",
|
||||
AWS_SECRET_ACCESS_KEY: "secret-fake-secret",
|
||||
AWS_SESSION_TOKEN: "session-fake-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("assembles AWS extraEnv for profile auth mode plus advanced flags", async () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "profile",
|
||||
region: "eu-west-1",
|
||||
awsProfile: "production",
|
||||
forceCache: true,
|
||||
endpointUrl: "https://bedrock-runtime.example.com",
|
||||
skipAuth: true,
|
||||
forceHttp1: true,
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("amazon-bedrock");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
AWS_REGION: "eu-west-1",
|
||||
AWS_PROFILE: "production",
|
||||
AWS_BEDROCK_FORCE_CACHE: "1",
|
||||
AWS_ENDPOINT_URL_BEDROCK_RUNTIME: "https://bedrock-runtime.example.com",
|
||||
AWS_BEDROCK_SKIP_AUTH: "1",
|
||||
AWS_BEDROCK_FORCE_HTTP1: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("assembles Azure extraEnv with deploymentMap", async () => {
|
||||
store.save("azure-openai", {
|
||||
kind: "azure",
|
||||
value: {
|
||||
apiKey: "azure-secret-key",
|
||||
baseUrl: "https://my-resource.openai.azure.com/",
|
||||
resourceName: "my-resource",
|
||||
apiVersion: "2024-08-01-preview",
|
||||
deploymentMap: { "gpt-4o": "gpt4-deploy", "gpt-4o-mini": "gpt4mini-deploy" },
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("azure-openai");
|
||||
expect(resolved.apiKey).toBe("azure-secret-key");
|
||||
expect(resolved.extraEnv).toMatchObject({
|
||||
AZURE_OPENAI_API_KEY: "azure-secret-key",
|
||||
AZURE_OPENAI_BASE_URL: "https://my-resource.openai.azure.com/",
|
||||
AZURE_OPENAI_RESOURCE_NAME: "my-resource",
|
||||
AZURE_OPENAI_API_VERSION: "2024-08-01-preview",
|
||||
});
|
||||
expect(resolved.extraEnv?.AZURE_OPENAI_DEPLOYMENT_NAME_MAP).toContain("gpt-4o=gpt4-deploy");
|
||||
});
|
||||
|
||||
it("assembles Vertex extraEnv from project + location + credentials path", async () => {
|
||||
store.save("google-vertex", {
|
||||
kind: "gcp_adc",
|
||||
value: {
|
||||
project: "my-gcp-project",
|
||||
location: "us-central1",
|
||||
credentialsPath: "/etc/gcp/service-account.json",
|
||||
},
|
||||
});
|
||||
const resolved = await resolver.resolve("google-vertex");
|
||||
expect(resolved.apiKey).toBe("");
|
||||
expect(resolved.extraEnv).toEqual({
|
||||
GOOGLE_CLOUD_PROJECT: "my-gcp-project",
|
||||
GOOGLE_CLOUD_LOCATION: "us-central1",
|
||||
GOOGLE_APPLICATION_CREDENTIALS: "/etc/gcp/service-account.json",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
|
||||
beforeAll(() => {
|
||||
// SecretVault throws unless safeStorage is available or this gate is set.
|
||||
// safeStorage is unavailable inside vitest, so we use the test-only path.
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
describe("CredentialStore", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-credential-store-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("round-trips an api_key literal credential", () => {
|
||||
const summary = store.save("anthropic", { kind: "api_key", value: { source: "literal", value: "sk-ant-test1234567890abcd" } });
|
||||
expect(summary.kind).toBe("api_key");
|
||||
expect(summary.status).toBe("active");
|
||||
expect(summary.displayHint).toContain("...");
|
||||
expect(summary.displayHint).not.toContain("test1234567890");
|
||||
|
||||
const decrypted = store.read("anthropic");
|
||||
expect(decrypted).not.toBeNull();
|
||||
expect(decrypted!.kind).toBe("api_key");
|
||||
if (decrypted!.kind === "api_key") {
|
||||
expect(decrypted!.value.source).toBe("literal");
|
||||
expect(decrypted!.value.value).toBe("sk-ant-test1234567890abcd");
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips an aws credential with bearer mode", () => {
|
||||
store.save("amazon-bedrock", {
|
||||
kind: "aws",
|
||||
value: {
|
||||
authMode: "bearer",
|
||||
region: "us-east-2",
|
||||
bearerToken: "bedrock-bearer-xyz",
|
||||
},
|
||||
});
|
||||
const decrypted = store.read("amazon-bedrock");
|
||||
expect(decrypted?.kind).toBe("aws");
|
||||
if (decrypted?.kind === "aws") {
|
||||
expect(decrypted.value.authMode).toBe("bearer");
|
||||
expect(decrypted.value.region).toBe("us-east-2");
|
||||
expect(decrypted.value.bearerToken).toBe("bedrock-bearer-xyz");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes a credential", () => {
|
||||
store.save("openai", { kind: "api_key", value: { source: "literal", value: "sk-test-abcdefghij" } });
|
||||
expect(store.list().some((row) => row.connectorId === "openai")).toBe(true);
|
||||
store.remove("openai");
|
||||
expect(store.list().some((row) => row.connectorId === "openai")).toBe(false);
|
||||
expect(store.read("openai")).toBeNull();
|
||||
});
|
||||
|
||||
it("setStatus updates the status row without losing ciphertext", () => {
|
||||
store.save("groq", { kind: "api_key", value: { source: "env", value: "GROQ_API_KEY" } });
|
||||
store.setStatus("groq", "expired", "refresh failed");
|
||||
const row = store.list().find((entry) => entry.connectorId === "groq");
|
||||
expect(row?.status).toBe("expired");
|
||||
expect(row?.lastError).toBe("refresh failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
apiKeyValueSchema,
|
||||
awsCredentialSchema,
|
||||
azureCredentialSchema,
|
||||
credentialPayloadSchema,
|
||||
gcpAdcCredentialSchema,
|
||||
removeCredentialInputSchema,
|
||||
saveCredentialInputSchema,
|
||||
testCredentialInputSchema,
|
||||
} from "../../src/shared/contracts/schemas-credentials";
|
||||
import {
|
||||
oauthCancelInputSchema,
|
||||
oauthManualCodeResponseSchema,
|
||||
oauthPromptResponseSchema,
|
||||
oauthStartInputSchema,
|
||||
} from "../../src/shared/contracts/schemas-oauth";
|
||||
|
||||
/** Verifies the IPC payload schemas reject malformed input and accept valid
|
||||
* shapes. These are the only contract between renderer ↔ main for the new
|
||||
* credential / OAuth channels — incorrect validation would let bad data
|
||||
* reach pi-mono primitives. */
|
||||
|
||||
describe("credentials Zod schemas", () => {
|
||||
it("apiKeyValueSchema rejects empty value", () => {
|
||||
const result = apiKeyValueSchema.safeParse({ source: "literal", value: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("apiKeyValueSchema accepts both source kinds", () => {
|
||||
expect(apiKeyValueSchema.safeParse({ source: "literal", value: "sk-x" }).success).toBe(true);
|
||||
expect(apiKeyValueSchema.safeParse({ source: "env", value: "MY_KEY" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("apiKeyValueSchema rejects unknown source", () => {
|
||||
const result = apiKeyValueSchema.safeParse({ source: "shell", value: "$(echo x)" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("awsCredentialSchema requires authMode", () => {
|
||||
expect(awsCredentialSchema.safeParse({ region: "us-east-1" }).success).toBe(false);
|
||||
expect(awsCredentialSchema.safeParse({ authMode: "profile", region: "us-east-1", awsProfile: "default" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("awsCredentialSchema accepts all three auth modes", () => {
|
||||
for (const authMode of ["profile", "iam", "bearer"] as const) {
|
||||
const result = awsCredentialSchema.safeParse({ authMode, region: "us-east-1" });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("azureCredentialSchema requires apiKey", () => {
|
||||
expect(azureCredentialSchema.safeParse({}).success).toBe(false);
|
||||
expect(azureCredentialSchema.safeParse({ apiKey: "azure-secret" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("gcpAdcCredentialSchema requires project + location", () => {
|
||||
expect(gcpAdcCredentialSchema.safeParse({ project: "" }).success).toBe(false);
|
||||
expect(gcpAdcCredentialSchema.safeParse({ project: "p", location: "us-central1" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("credentialPayloadSchema discriminates on kind", () => {
|
||||
expect(
|
||||
credentialPayloadSchema.safeParse({
|
||||
kind: "api_key",
|
||||
value: { source: "literal", value: "sk-x" },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
credentialPayloadSchema.safeParse({
|
||||
kind: "azure",
|
||||
value: { source: "literal", value: "wrong-shape" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("saveCredentialInputSchema requires connectorId + payload", () => {
|
||||
expect(
|
||||
saveCredentialInputSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
payload: { kind: "api_key", value: { source: "literal", value: "sk-x" } },
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(saveCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
expect(saveCredentialInputSchema.safeParse({ payload: {} }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("removeCredentialInputSchema rejects empty connectorId", () => {
|
||||
expect(removeCredentialInputSchema.safeParse({ connectorId: "openai" }).success).toBe(true);
|
||||
expect(removeCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
expect(removeCredentialInputSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("testCredentialInputSchema rejects empty connectorId", () => {
|
||||
expect(testCredentialInputSchema.safeParse({ connectorId: "openai" }).success).toBe(true);
|
||||
expect(testCredentialInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauth Zod schemas", () => {
|
||||
it("oauthStartInputSchema rejects empty connectorId", () => {
|
||||
expect(oauthStartInputSchema.safeParse({ connectorId: "claude-pro" }).success).toBe(true);
|
||||
expect(oauthStartInputSchema.safeParse({ connectorId: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthCancelInputSchema rejects missing connectorId", () => {
|
||||
expect(oauthCancelInputSchema.safeParse({ connectorId: "claude-pro" }).success).toBe(true);
|
||||
expect(oauthCancelInputSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthPromptResponseSchema requires connectorId, requestId and a (possibly empty) value", () => {
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({
|
||||
connectorId: "github-copilot",
|
||||
requestId: "req-1",
|
||||
value: "github.com",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({ connectorId: "github-copilot", requestId: "" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
oauthPromptResponseSchema.safeParse({ connectorId: "github-copilot", value: "x" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("oauthManualCodeResponseSchema requires non-empty code", () => {
|
||||
expect(
|
||||
oauthManualCodeResponseSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
requestId: "req-1",
|
||||
code: "abcd",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
oauthManualCodeResponseSchema.safeParse({
|
||||
connectorId: "anthropic",
|
||||
requestId: "req-1",
|
||||
code: "",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function makeRun(overrides: Partial<RunRecord> = {}): RunRecord {
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
status: "succeeded",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
@@ -47,7 +47,7 @@ function makeCost(overrides: Partial<CostRecord> = {}): CostRecord {
|
||||
runId: "run-1",
|
||||
taskId: null,
|
||||
agentId: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
amountUsd: 0.5,
|
||||
unattributed: false,
|
||||
createdAt: "2026-03-20T10:00:00Z",
|
||||
@@ -67,7 +67,7 @@ function makeAgent(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
||||
department: "engineering",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "[]",
|
||||
|
||||
+16
-16
@@ -54,7 +54,7 @@ describe("AppDatabase", () => {
|
||||
title: "Lead operator",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5",
|
||||
capabilities: "Directs execution",
|
||||
@@ -95,7 +95,7 @@ describe("AppDatabase", () => {
|
||||
value: "sk-test-secret",
|
||||
});
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "OPENAI_API_KEY=vault-openai",
|
||||
@@ -189,14 +189,14 @@ describe("AppDatabase", () => {
|
||||
title: "Lead",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Leads",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
@@ -217,14 +217,14 @@ describe("AppDatabase", () => {
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
const { id: runId2 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId1)).toBe(true);
|
||||
@@ -255,7 +255,7 @@ describe("AppDatabase", () => {
|
||||
title: "Budget Owner",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Ships work",
|
||||
@@ -276,7 +276,7 @@ describe("AppDatabase", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
});
|
||||
|
||||
const outcome = db.finishRun({
|
||||
@@ -310,7 +310,7 @@ describe("AppDatabase", () => {
|
||||
title: "Manager",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Manages",
|
||||
@@ -323,7 +323,7 @@ describe("AppDatabase", () => {
|
||||
title: "Pending Hire",
|
||||
status: "pending_approval",
|
||||
reportsTo: managerId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Will execute after approval",
|
||||
@@ -362,7 +362,7 @@ describe("AppDatabase", () => {
|
||||
title: "Active",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Ships",
|
||||
@@ -375,7 +375,7 @@ describe("AppDatabase", () => {
|
||||
title: "Pending",
|
||||
status: "pending_approval",
|
||||
reportsTo: activeAgentId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Waiting",
|
||||
@@ -479,7 +479,7 @@ describe("AppDatabase", () => {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: wsId,
|
||||
model: null,
|
||||
capabilities: "test",
|
||||
@@ -495,7 +495,7 @@ describe("AppDatabase", () => {
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId: wsId, connectorId: "codex_local" });
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId: wsId, connectorId: "openai-codex" });
|
||||
await db.appendRunLog(run.id, "stdout", "test log line\n");
|
||||
|
||||
// Finish the run with a very old timestamp
|
||||
@@ -544,7 +544,7 @@ describe("AppDatabase", () => {
|
||||
title: "CEO",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "manage",
|
||||
@@ -557,7 +557,7 @@ describe("AppDatabase", () => {
|
||||
title: "IC",
|
||||
status: "pending_approval",
|
||||
reportsTo: managerId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "code",
|
||||
|
||||
@@ -26,7 +26,7 @@ function scaffold(db: AppDatabase) {
|
||||
status: "active",
|
||||
});
|
||||
db.saveConnector({
|
||||
id: "gemini_local",
|
||||
id: "gemini-cli",
|
||||
command: "gemini",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
@@ -47,7 +47,7 @@ function scaffold(db: AppDatabase) {
|
||||
title: "Developer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
@@ -134,7 +134,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run1.id);
|
||||
db.markRunStatus(run1.id, "running", "");
|
||||
@@ -154,7 +154,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId: taskId2,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId2, run0.id);
|
||||
db.markRunStatus(run0.id, "running", "");
|
||||
@@ -222,7 +222,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run.id);
|
||||
db.markRunStatus(run.id, "running", "");
|
||||
@@ -271,7 +271,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run.id);
|
||||
db.markRunStatus(run.id, "running", "");
|
||||
@@ -329,7 +329,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run.id);
|
||||
db.markRunStatus(run.id, "running", "");
|
||||
@@ -382,7 +382,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run.id);
|
||||
db.markRunStatus(run.id, "running", "");
|
||||
@@ -429,7 +429,7 @@ describe("Edge Case: Budget Exhaustion Mid-Run (D-05.2)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
db.checkoutTask(taskId, run.id);
|
||||
db.markRunStatus(run.id, "running", "");
|
||||
|
||||
@@ -37,7 +37,7 @@ function createTestCompany(db: AppDatabase): string {
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "gemini_local",
|
||||
id: "gemini-cli",
|
||||
command: "gemini",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
@@ -64,7 +64,7 @@ function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
@@ -116,14 +116,14 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const { id: runIdB } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
|
||||
// Agent-A claims first
|
||||
@@ -152,14 +152,14 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId: taskId1,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const { id: runIdB1 } = db.createRun({
|
||||
companyId,
|
||||
taskId: taskId1,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
|
||||
// Agent-A wins first task
|
||||
@@ -174,7 +174,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId: taskId2,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const resultB2 = db.checkoutTask(taskId2, runIdB2);
|
||||
expect(resultB2).toBe(true);
|
||||
@@ -198,7 +198,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(false);
|
||||
@@ -231,7 +231,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
@@ -252,7 +252,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(false);
|
||||
@@ -270,7 +270,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
@@ -306,7 +306,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentA,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
expect(db.checkoutTask(taskId, runIdA)).toBe(true);
|
||||
|
||||
@@ -328,7 +328,7 @@ describe("Edge Case: Concurrent Task Checkout (D-05.1)", () => {
|
||||
taskId,
|
||||
agentId: agentB,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local",
|
||||
connectorId: "gemini-cli",
|
||||
});
|
||||
const result = db.checkoutTask(taskId, runIdB);
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -20,16 +20,16 @@ import type { ConnectorId } from "@shared/types";
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scaffold(db: AppDatabase, connectorId: ConnectorId = "gemini_local") {
|
||||
function scaffold(db: AppDatabase, connectorId: ConnectorId = "gemini-cli") {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Session Edge Case Co",
|
||||
description: "Session rotation edge case tests (D-05.3)",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Always save gemini_local (needed for most tests)
|
||||
// Always save gemini-cli (needed for most tests)
|
||||
db.saveConnector({
|
||||
id: "gemini_local",
|
||||
id: "gemini-cli",
|
||||
command: "gemini",
|
||||
model: "gemini-pro",
|
||||
envBindingText: "",
|
||||
@@ -119,7 +119,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
expect(session!.runCount).toBe(DEFAULT_THRESHOLDS.maxRuns);
|
||||
|
||||
// Evaluate rotation -- should trigger
|
||||
const result = sessionService.evaluateRotation(session!, "gemini_local");
|
||||
const result = sessionService.evaluateRotation(session!, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_runs");
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
const totalTokens = session!.totalInputTokens + session!.totalOutputTokens;
|
||||
expect(totalTokens).toBe(550000);
|
||||
|
||||
const result = sessionService.evaluateRotation(session!, "gemini_local");
|
||||
const result = sessionService.evaluateRotation(session!, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_tokens");
|
||||
});
|
||||
@@ -175,7 +175,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -191,7 +191,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local" as ConnectorId,
|
||||
connectorId: "gemini-cli" as ConnectorId,
|
||||
});
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
@@ -233,7 +233,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: JSON.stringify({ resumeKey: "abc" }),
|
||||
sessionDisplayId: "gemini-session-1",
|
||||
});
|
||||
@@ -249,7 +249,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: JSON.stringify({ resumeKey: "abc" }),
|
||||
sessionDisplayId: "gemini-session-2",
|
||||
});
|
||||
@@ -266,15 +266,15 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
expect(newSession!.totalOutputTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("claude connector is exempt from rotation", () => {
|
||||
const { companyId, agentId, taskId } = scaffold(db, "gemini_local");
|
||||
it("claude connector triggers rotation under pi-mono (no native exemption)", () => {
|
||||
const { companyId, agentId, taskId } = scaffold(db, "gemini-cli");
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -285,21 +285,21 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
}
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
// Per Pitfall 6: claude_local has nativeContextManagement: true, exempt from rotation
|
||||
const result = sessionService.evaluateRotation(session, "claude_local");
|
||||
expect(result.shouldRotate).toBe(false);
|
||||
expect(result.reason).toBeNull();
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session, "claude-pro");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).not.toBeNull();
|
||||
});
|
||||
|
||||
it("codex connector is exempt from rotation", () => {
|
||||
const { companyId, agentId, taskId } = scaffold(db, "gemini_local");
|
||||
it("codex connector triggers rotation under pi-mono (no native exemption)", () => {
|
||||
const { companyId, agentId, taskId } = scaffold(db, "gemini-cli");
|
||||
const sessionService = createSessionManagementService({ db, logger: mockLogger });
|
||||
|
||||
const sessionId = db.createNewSession({
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "codex_local",
|
||||
adapterType: "openai-codex",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -310,10 +310,10 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
}
|
||||
|
||||
const session = db.getActiveTaskSession(taskId)!;
|
||||
// Per Pitfall 6: codex_local has nativeContextManagement: true, exempt from rotation
|
||||
const result = sessionService.evaluateRotation(session, "codex_local");
|
||||
expect(result.shouldRotate).toBe(false);
|
||||
expect(result.reason).toBeNull();
|
||||
// Pi-mono delegates compaction to pi-agent-core; AgentCompany still applies rotation uniformly.
|
||||
const result = sessionService.evaluateRotation(session, "openai-codex");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).not.toBeNull();
|
||||
});
|
||||
|
||||
it("handoff markdown available for next run context after rotation", () => {
|
||||
@@ -325,7 +325,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -337,7 +337,7 @@ describe("Edge Case: Session Rotation During Execution (D-05.3)", () => {
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "gemini_local" as ConnectorId,
|
||||
connectorId: "gemini-cli" as ConnectorId,
|
||||
});
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
|
||||
@@ -28,7 +28,7 @@ function createTestCompany(db: AppDatabase): string {
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
@@ -55,7 +55,7 @@ function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
|
||||
@@ -32,13 +32,13 @@ describe("ipc-core-agent-graph-handlers model enforcement", () => {
|
||||
db.init();
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
lastError: null,
|
||||
});
|
||||
db.setDisabledModels("codex_local", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
db.setDisabledModels("openai-codex", [{ id: "gpt-5.4", label: "gpt-5.4" }]);
|
||||
|
||||
const companyId = db.saveCompany({
|
||||
name: "Handler Test Co",
|
||||
@@ -60,7 +60,7 @@ describe("ipc-core-agent-graph-handlers model enforcement", () => {
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -93,7 +93,7 @@ describe("ipc-core-agent-graph-handlers model enforcement", () => {
|
||||
department: "executive",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: "gpt-5.4",
|
||||
metadataJson: "{}",
|
||||
|
||||
@@ -26,11 +26,17 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("ipc-core-admin-handlers connector saves", () => {
|
||||
it("accepts compatible connector model catalogs stored as csv", async () => {
|
||||
it("accepts a valid pi-ai model for a pi-mono connector", async () => {
|
||||
const profileDir = await createTempDir("agentcompany-admin-handler-");
|
||||
const db = new AppDatabase(profileDir);
|
||||
db.init();
|
||||
|
||||
const { getModels } = await import("@mariozechner/pi-ai");
|
||||
const anthropicModel = getModels("anthropic" as never)[0]?.id;
|
||||
if (!anthropicModel) {
|
||||
throw new Error("pi-ai anthropic provider must expose at least one model.");
|
||||
}
|
||||
|
||||
const handlers = new Map<string, IpcHandler>();
|
||||
registerCoreAdminHandlers({
|
||||
registerHandle: (channel, handler) => {
|
||||
@@ -50,16 +56,26 @@ describe("ipc-core-admin-handlers connector saves", () => {
|
||||
throw new Error("saveConnector handler was not registered.");
|
||||
}
|
||||
|
||||
// Pi-mono connectors default to an empty command; the schema requires a
|
||||
// non-empty one, so seed it before hitting the IPC handler.
|
||||
db.saveConnector({
|
||||
id: "anthropic",
|
||||
command: "pi-mono",
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const result = await saveConnector(null as unknown as IpcMainInvokeEvent, {
|
||||
id: "claude_compatible",
|
||||
command: "claude",
|
||||
model: "model-alpha,model-beta,model-gamma",
|
||||
id: "anthropic",
|
||||
command: "pi-mono",
|
||||
model: anthropicModel,
|
||||
envBindingText: "ANTHROPIC_BASE_URL=https://example.com/anthropic",
|
||||
notes: "Compat provider",
|
||||
notes: "Pi-mono connector",
|
||||
}) as { ok: boolean; error?: { code: string; message: string } };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getConnector("claude_compatible").model).toBe("model-alpha,model-beta,model-gamma");
|
||||
expect(db.getConnector("anthropic").model).toBe(anthropicModel);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactTokens } from "../../src/main/worker/log-redactor";
|
||||
|
||||
/** Verifies the worker process token redactor strips known
|
||||
* credential-shaped substrings from log strings before they leave the
|
||||
* worker process. */
|
||||
describe("worker log redactor", () => {
|
||||
it("redacts Bearer tokens", () => {
|
||||
const input = "Authorization: Bearer abc.def.ghi-jkl_mnop";
|
||||
expect(redactTokens(input)).toBe("Authorization: Bearer [REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts authorization header values regardless of case", () => {
|
||||
expect(redactTokens("authorization=secret-value-1234567890")).toContain("[REDACTED]");
|
||||
expect(redactTokens("Authorization: secret-value-1234567890")).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts access_token query strings", () => {
|
||||
expect(redactTokens("https://api.example.com/?access_token=abc123xyz&foo=1")).toBe(
|
||||
"https://api.example.com/?access_token=[REDACTED]&foo=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts refresh_token values", () => {
|
||||
expect(redactTokens("refresh_token=long.refresh.tok&grant_type=refresh_token")).toContain(
|
||||
"refresh_token=[REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts api_key key/value pairs", () => {
|
||||
const input = "config { api_key: \"sk-test-1234567890abcdef\", model: \"gpt-4o\" }";
|
||||
const result = redactTokens(input);
|
||||
expect(result).not.toContain("sk-test-1234567890abcdef");
|
||||
expect(result).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts sk- and sk-ant- prefixed keys anywhere in a string", () => {
|
||||
expect(redactTokens("error using sk-ant-api03_abcdefghijklmnop, retrying")).toContain("sk-[REDACTED]");
|
||||
expect(redactTokens("token=sk-test1234567890_abcd")).toContain("sk-[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts GitHub personal access token shapes", () => {
|
||||
expect(redactTokens("git push https://x:ghp_aaaaaaaaaaaaaaaaaa@github.com/foo")).toContain("ghp_[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts AWS access key id prefixes", () => {
|
||||
expect(redactTokens("aws cred AKIAIOSFODNN7EXAMPLE in trace")).toContain("AKIA[REDACTED]");
|
||||
});
|
||||
|
||||
it("leaves credential-free strings unchanged", () => {
|
||||
const safe = "Just a regular log line about model claude-opus-4-5 with usage 123 tokens.";
|
||||
expect(redactTokens(safe)).toBe(safe);
|
||||
});
|
||||
|
||||
it("redacts multiple matches in one string", () => {
|
||||
const input = "Bearer abc.def.ghi.jkl Authorization=secretsecretsecret";
|
||||
const result = redactTokens(input);
|
||||
expect(result).not.toContain("abc.def.ghi.jkl");
|
||||
expect(result).not.toContain("secretsecretsecret");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ afterEach(async () => {
|
||||
|
||||
function createScaffold(db: AppDatabase) {
|
||||
db.updateConnectorHealth({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
status: "ready",
|
||||
authState: "authenticated",
|
||||
version: "test",
|
||||
@@ -48,7 +48,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Chief Executive Officer",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
@@ -61,7 +61,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Chief Technology Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -75,7 +75,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Chief Marketing Officer",
|
||||
status: "idle",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Lead marketing",
|
||||
@@ -89,7 +89,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Pending Hire",
|
||||
status: "pending_approval",
|
||||
reportsTo: ctoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Pending",
|
||||
@@ -103,7 +103,7 @@ function createScaffold(db: AppDatabase) {
|
||||
title: "Former COO",
|
||||
status: "terminated",
|
||||
reportsTo: ceoId,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "No longer active",
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("AppDatabase.searchMessages", () => {
|
||||
title: "Senior Engineer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "coding",
|
||||
@@ -53,7 +53,7 @@ describe("AppDatabase.searchMessages", () => {
|
||||
title: "Lead Designer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "design",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { CredentialStore } from "../../src/main/credentials/credential-store";
|
||||
import { OAuthLoginService } from "../../src/main/credentials/oauth-login-service";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
shell: { openExternal: vi.fn(async () => true) },
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
|
||||
});
|
||||
|
||||
const noopLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
|
||||
describe("OAuthLoginService", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let store: CredentialStore;
|
||||
let service: OAuthLoginService;
|
||||
const emit = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-oauth-service-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
store = new CredentialStore({ db });
|
||||
service = new OAuthLoginService({
|
||||
store,
|
||||
emitEvent: emit,
|
||||
refreshConnectorHealth: async () => {},
|
||||
logger: noopLogger,
|
||||
});
|
||||
emit.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects start() for a non-OAuth connector", async () => {
|
||||
await expect(service.start("anthropic")).rejects.toThrow(/does not use OAuth/);
|
||||
});
|
||||
|
||||
it("rejects start() for an unknown connector id", async () => {
|
||||
await expect(service.start("definitely-not-a-connector")).rejects.toThrow(/Unknown connector/);
|
||||
});
|
||||
|
||||
it("submitPromptResponse returns false when there is no pending prompt", () => {
|
||||
expect(service.submitPromptResponse("nonexistent-request-id", "value")).toBe(false);
|
||||
});
|
||||
|
||||
it("submitManualCode returns false when there is no pending request", () => {
|
||||
expect(service.submitManualCode("nonexistent-request-id", "code")).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel() of a connector with no active flow is a no-op (does not throw)", () => {
|
||||
expect(() => service.cancel("claude-pro")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function buildCtx(overrides: Partial<PromptContext> = {}): PromptContext {
|
||||
department: "engineering",
|
||||
status: "active",
|
||||
reportsTo: "ceo-1",
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Lead engineering",
|
||||
@@ -46,7 +46,7 @@ function buildCtx(overrides: Partial<PromptContext> = {}): PromptContext {
|
||||
department: "executive",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "Lead the company",
|
||||
|
||||
@@ -41,7 +41,7 @@ function scaffold(db: AppDatabase) {
|
||||
title: "Developer",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId,
|
||||
model: "claude-4-opus",
|
||||
capabilities: "Writes code",
|
||||
@@ -107,7 +107,7 @@ describe("task_sessions CRUD", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: JSON.stringify({ sessionId: "abc123" }),
|
||||
sessionDisplayId: "claude-abc123",
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe("task_sessions CRUD", () => {
|
||||
expect(session!.taskId).toBe(taskId);
|
||||
expect(session!.companyId).toBe(companyId);
|
||||
expect(session!.agentId).toBe(agentId);
|
||||
expect(session!.adapterType).toBe("claude_local");
|
||||
expect(session!.adapterType).toBe("claude-pro");
|
||||
expect(session!.sessionParamsJson).toBe(JSON.stringify({ sessionId: "abc123" }));
|
||||
expect(session!.sessionDisplayId).toBe("claude-abc123");
|
||||
expect(session!.runCount).toBe(0);
|
||||
@@ -135,7 +135,7 @@ describe("task_sessions CRUD", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe("task_sessions CRUD", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -183,7 +183,7 @@ describe("task_sessions CRUD", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: JSON.stringify({ key: "val" }),
|
||||
sessionDisplayId: "gemini-session-1",
|
||||
});
|
||||
@@ -195,14 +195,14 @@ describe("task_sessions CRUD", () => {
|
||||
const session = db.getActiveTaskSession(taskId);
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toBe(sessionId);
|
||||
expect(session!.adapterType).toBe("gemini_local");
|
||||
expect(session!.adapterType).toBe("gemini-cli");
|
||||
});
|
||||
|
||||
it("getRecentRunsForTask returns last N runs ordered by finished_at DESC", () => {
|
||||
const { taskId, companyId, agentId, workspaceId } = scaffold(db);
|
||||
|
||||
// Create some runs
|
||||
const run1 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude_local" });
|
||||
const run1 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude-pro" });
|
||||
db.finishRun({
|
||||
runId: run1.id,
|
||||
status: "succeeded",
|
||||
@@ -216,7 +216,7 @@ describe("task_sessions CRUD", () => {
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const run2 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude_local" });
|
||||
const run2 = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "claude-pro" });
|
||||
db.finishRun({
|
||||
runId: run2.id,
|
||||
status: "failed",
|
||||
@@ -242,22 +242,25 @@ describe("task_sessions CRUD", () => {
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// adapter exemption
|
||||
// connector definition lookup
|
||||
// ===========================================================================
|
||||
describe("adapter exemption", () => {
|
||||
it("claude_local connector has nativeContextManagement: true", () => {
|
||||
const def = getConnectorDefinition("claude_local" as ConnectorId);
|
||||
expect(def.module.nativeContextManagement).toBe(true);
|
||||
describe("connector definition lookup", () => {
|
||||
it("resolves claude-pro definition from the pi-mono catalog", () => {
|
||||
const def = getConnectorDefinition("claude-pro" as ConnectorId);
|
||||
expect(def.id).toBe("claude-pro");
|
||||
expect(def.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("codex_local connector has nativeContextManagement: true", () => {
|
||||
const def = getConnectorDefinition("codex_local" as ConnectorId);
|
||||
expect(def.module.nativeContextManagement).toBe(true);
|
||||
it("resolves openai-codex definition from the pi-mono catalog", () => {
|
||||
const def = getConnectorDefinition("openai-codex" as ConnectorId);
|
||||
expect(def.id).toBe("openai-codex");
|
||||
expect(def.provider).toBe("openai-codex");
|
||||
});
|
||||
|
||||
it("gemini_local connector has nativeContextManagement: false", () => {
|
||||
const def = getConnectorDefinition("gemini_local" as ConnectorId);
|
||||
expect(def.module.nativeContextManagement).toBe(false);
|
||||
it("resolves gemini-cli definition from the pi-mono catalog", () => {
|
||||
const def = getConnectorDefinition("gemini-cli" as ConnectorId);
|
||||
expect(def.id).toBe("gemini-cli");
|
||||
expect(def.provider).toBe("google-gemini-cli");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,7 +301,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -312,7 +315,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local");
|
||||
const result = svc.evaluateRotation(session, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(false);
|
||||
expect(result.reason).toBeNull();
|
||||
});
|
||||
@@ -324,7 +327,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -338,7 +341,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local");
|
||||
const result = svc.evaluateRotation(session, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_runs");
|
||||
});
|
||||
@@ -350,7 +353,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -364,7 +367,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local");
|
||||
const result = svc.evaluateRotation(session, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_tokens");
|
||||
});
|
||||
@@ -377,7 +380,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -391,24 +394,24 @@ describe("session management service", () => {
|
||||
createdAt: oldDate,
|
||||
updatedAt: oldDate,
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local");
|
||||
const result = svc.evaluateRotation(session, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_age_hours");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// adapter exemption in service
|
||||
// uniform rotation under pi-mono
|
||||
// -------------------------------------------------------------------------
|
||||
describe("adapter exemption in evaluateRotation", () => {
|
||||
it("returns shouldRotate: false for nativeContextManagement=true even when thresholds exceeded", () => {
|
||||
describe("uniform rotation under pi-mono", () => {
|
||||
it("returns shouldRotate: true for all connectors when thresholds exceeded (pi-mono has no native exemption)", () => {
|
||||
const svc = createSessionManagementService({ db, logger: mockLogger });
|
||||
const session: TaskSessionRecord = {
|
||||
id: "sess-1",
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -422,9 +425,9 @@ describe("session management service", () => {
|
||||
createdAt: new Date(Date.now() - 200 * 60 * 60 * 1000).toISOString(), // Way old
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "claude_local");
|
||||
expect(result.shouldRotate).toBe(false);
|
||||
expect(result.reason).toBeNull();
|
||||
const result = svc.evaluateRotation(session, "claude-pro");
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,7 +442,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -453,7 +456,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local", { maxRuns: 5 });
|
||||
const result = svc.evaluateRotation(session, "gemini-cli", { maxRuns: 5 });
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_runs");
|
||||
});
|
||||
@@ -465,7 +468,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -479,7 +482,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local", { maxTokens: 100000 });
|
||||
const result = svc.evaluateRotation(session, "gemini-cli", { maxTokens: 100000 });
|
||||
expect(result.shouldRotate).toBe(true);
|
||||
expect(result.reason).toBe("max_tokens");
|
||||
});
|
||||
@@ -491,7 +494,7 @@ describe("session management service", () => {
|
||||
companyId: "co-1",
|
||||
taskId: "task-1",
|
||||
agentId: "agent-1",
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
lastRunId: null,
|
||||
@@ -505,7 +508,7 @@ describe("session management service", () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const result = svc.evaluateRotation(session, "gemini_local");
|
||||
const result = svc.evaluateRotation(session, "gemini-cli");
|
||||
expect(result.shouldRotate).toBe(false);
|
||||
expect(result.reason).toBeNull();
|
||||
|
||||
@@ -526,7 +529,7 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "claude_local",
|
||||
adapterType: "claude-pro",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -552,13 +555,13 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
|
||||
// Create a completed run for history
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "gemini_local" as ConnectorId });
|
||||
const run = db.createRun({ companyId, taskId, agentId, workspaceId, connectorId: "gemini-cli" as ConnectorId });
|
||||
db.finishRun({
|
||||
runId: run.id,
|
||||
status: "succeeded",
|
||||
@@ -597,7 +600,7 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: JSON.stringify({ resumeId: "old-abc" }),
|
||||
sessionDisplayId: "gemini-old",
|
||||
});
|
||||
@@ -644,7 +647,7 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -663,7 +666,7 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -684,7 +687,7 @@ describe("session management service", () => {
|
||||
taskId,
|
||||
companyId,
|
||||
agentId,
|
||||
adapterType: "gemini_local",
|
||||
adapterType: "gemini-cli",
|
||||
sessionParamsJson: null,
|
||||
sessionDisplayId: null,
|
||||
});
|
||||
@@ -773,7 +776,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.accumulateTokenUsage).toHaveBeenCalledWith("sess-1", 200, 100, "run-1");
|
||||
@@ -789,10 +792,10 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.evaluateRotation).toHaveBeenCalledWith(mockSession, "gemini_local");
|
||||
expect(sessionService.evaluateRotation).toHaveBeenCalledWith(mockSession, "gemini-cli");
|
||||
// evaluateRotation called AFTER accumulateTokenUsage
|
||||
const accOrder = sessionService.accumulateTokenUsage.mock.invocationCallOrder[0];
|
||||
const evalOrder = sessionService.evaluateRotation.mock.invocationCallOrder[0];
|
||||
@@ -815,7 +818,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.rotateSession).toHaveBeenCalledWith(updatedSession);
|
||||
@@ -832,7 +835,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.rotateSession).not.toHaveBeenCalled();
|
||||
@@ -847,7 +850,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
null,
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(db.getActiveTaskSession).not.toHaveBeenCalled();
|
||||
@@ -863,7 +866,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: 200, outputTokens: 100, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.accumulateTokenUsage).not.toHaveBeenCalled();
|
||||
@@ -879,7 +882,7 @@ describe("wiring integration", () => {
|
||||
db,
|
||||
{ id: "task-1" },
|
||||
{ inputTokens: null, outputTokens: null, runId: "run-1" },
|
||||
"gemini_local",
|
||||
"gemini-cli",
|
||||
);
|
||||
|
||||
expect(sessionService.accumulateTokenUsage).toHaveBeenCalledWith("sess-1", 0, 0, "run-1");
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("buildConnectorSection", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: ["gpt-5.4", "gpt-5.4-mini"],
|
||||
@@ -22,7 +22,7 @@ describe("buildConnectorSection", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: ["gpt-5.4"],
|
||||
@@ -38,7 +38,7 @@ describe("buildConnectorSection", () => {
|
||||
const section = buildConnectorSection({
|
||||
allConnectors: [
|
||||
{
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
label: "Codex",
|
||||
status: "ready",
|
||||
enabledModels: [],
|
||||
|
||||
@@ -17,36 +17,35 @@ import type {
|
||||
|
||||
describe("skill-types", () => {
|
||||
describe("TOKEN_BUDGETS", () => {
|
||||
it("has entries for all supported adapter types", () => {
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("claude_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("codex_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("gemini_local");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("claude_compatible");
|
||||
it("has entries for all supported connector types", () => {
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("claude-pro");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("anthropic");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("gemini-cli");
|
||||
expect(TOKEN_BUDGETS).toHaveProperty("google");
|
||||
});
|
||||
|
||||
it("has numeric token caps for each adapter", () => {
|
||||
expect(TOKEN_BUDGETS.claude_local).toBe(50000);
|
||||
expect(TOKEN_BUDGETS.codex_local).toBe(30000);
|
||||
expect(TOKEN_BUDGETS.gemini_local).toBe(50000);
|
||||
expect(TOKEN_BUDGETS.claude_compatible).toBe(50000);
|
||||
it("has numeric token caps for each connector", () => {
|
||||
expect(TOKEN_BUDGETS["claude-pro"]).toBe(50000);
|
||||
expect(TOKEN_BUDGETS["anthropic"]).toBe(50000);
|
||||
expect(TOKEN_BUDGETS["gemini-cli"]).toBe(50000);
|
||||
expect(TOKEN_BUDGETS["google"]).toBe(50000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTokenBudget", () => {
|
||||
it("returns exact match for known adapter types", () => {
|
||||
expect(resolveTokenBudget("claude_local")).toBe(50000);
|
||||
expect(resolveTokenBudget("codex_local")).toBe(30000);
|
||||
expect(resolveTokenBudget("gemini_local")).toBe(50000);
|
||||
it("returns exact match for known connector types", () => {
|
||||
expect(resolveTokenBudget("claude-pro")).toBe(50000);
|
||||
expect(resolveTokenBudget("anthropic")).toBe(50000);
|
||||
expect(resolveTokenBudget("gemini-cli")).toBe(50000);
|
||||
});
|
||||
|
||||
it("resolves claude_compat_* variants to claude_compatible budget", () => {
|
||||
expect(resolveTokenBudget("claude_compat_2")).toBe(50000);
|
||||
expect(resolveTokenBudget("claude_compat_10")).toBe(50000);
|
||||
expect(resolveTokenBudget("claude_compatible")).toBe(50000);
|
||||
it("resolves anthropic-family connectors to the larger injection budget", () => {
|
||||
expect(resolveTokenBudget("amazon-bedrock")).toBe(50000);
|
||||
expect(resolveTokenBudget("google-vertex")).toBe(50000);
|
||||
});
|
||||
|
||||
it("returns 30000 default for unknown adapter types", () => {
|
||||
expect(resolveTokenBudget("unknown_adapter")).toBe(30000);
|
||||
it("returns 30000 default for unknown connector types", () => {
|
||||
expect(resolveTokenBudget("unknown_connector")).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
import { SqliteSessionStore } from "../../src/main/agent-runtime/sqlite-session-store";
|
||||
|
||||
/**
|
||||
* Verifies the SqliteSessionStore round-trips pi `AgentMessage` values
|
||||
* correctly through the new `session_entries` table (migration 27).
|
||||
*/
|
||||
describe("SqliteSessionStore", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
const TASK_SESSION_ID = "task-session-test-1";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-session-store-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("appends and reloads pi user/assistant messages preserving order", () => {
|
||||
const store = new SqliteSessionStore(db, TASK_SESSION_ID);
|
||||
const userMessage: AgentMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
} as AgentMessage;
|
||||
const assistantMessage: AgentMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
stopReason: "stop",
|
||||
provider: "anthropic",
|
||||
model: { id: "claude-opus-4-5" },
|
||||
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
timestamp: Date.now(),
|
||||
} as unknown as AgentMessage;
|
||||
store.appendMessage(userMessage);
|
||||
store.appendMessage(assistantMessage);
|
||||
const loaded = store.loadMessages();
|
||||
expect(loaded).toHaveLength(2);
|
||||
expect((loaded[0] as { role: string }).role).toBe("user");
|
||||
expect((loaded[1] as { role: string }).role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("appends a compaction marker that does not appear as a message", () => {
|
||||
const store = new SqliteSessionStore(db, TASK_SESSION_ID);
|
||||
store.appendMessage({ role: "user", content: [{ type: "text", text: "first" }], timestamp: Date.now() } as AgentMessage);
|
||||
store.appendCompaction("Earlier conversation summarised: planning phase complete.");
|
||||
const loaded = store.loadMessages();
|
||||
expect(loaded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaceMessages supersedes prior rows and inserts new ones in order", () => {
|
||||
const store = new SqliteSessionStore(db, TASK_SESSION_ID);
|
||||
store.appendMessage({ role: "user", content: [{ type: "text", text: "old-1" }], timestamp: Date.now() } as AgentMessage);
|
||||
store.appendMessage({ role: "assistant", content: [{ type: "text", text: "old-2" }], timestamp: Date.now() } as AgentMessage);
|
||||
|
||||
const compacted: AgentMessage[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "context summary" }], timestamp: Date.now() } as AgentMessage,
|
||||
];
|
||||
store.replaceMessages(compacted, "Summary of prior turns.");
|
||||
|
||||
const active = store.loadMessages();
|
||||
expect(active).toHaveLength(1);
|
||||
expect((active[0] as { content: { text: string }[] }).content[0]?.text).toBe("context summary");
|
||||
|
||||
// Check: pre-replace messages must still exist as superseded audit rows.
|
||||
const all = db.sessionEntries.listAll(TASK_SESSION_ID);
|
||||
const supersededCount = all.filter((row) => row.isSuperseded).length;
|
||||
expect(supersededCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -285,7 +285,7 @@ const fakeRun = (id: string): RunRecord => ({
|
||||
taskId: "t-1",
|
||||
agentId: "ag-1",
|
||||
workspaceId: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
status: "queued",
|
||||
summary: "",
|
||||
errorMessage: null,
|
||||
@@ -311,7 +311,7 @@ const fakeAgent = (id: string, name = "Agent"): AgentRecord => ({
|
||||
department: "engineering",
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
connectorId: "claude_local",
|
||||
connectorId: "claude-pro",
|
||||
workspaceId: null,
|
||||
model: null,
|
||||
capabilities: "",
|
||||
|
||||
@@ -28,7 +28,7 @@ function createTestCompany(db: AppDatabase): string {
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
id: "openai-codex",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
@@ -55,7 +55,7 @@ function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
connectorId: "openai-codex",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
|
||||
Reference in new issue
Block a user