Q173: agents work in the company's or task's language, not the display language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
538d834647
commit
3f14e3f706
36 files changed
+268
-105
No files matched your search
@@ -38,11 +38,12 @@ export const organizationContracts = {
|
||||
orgId: id, title: z.string(), description: z.string(), status: z.enum(TASK_STATUS), assigneeAgentId: nullableId, risk: z.enum(RISK_LEVEL),
|
||||
projectId: nullableId, goalId: nullableId, workspaceId: nullableId, priority: z.enum(TASK_PRIORITY), requiresFilesystem: z.boolean(),
|
||||
writingMode: z.enum(WRITING_MODE).optional(), origin: z.enum(["chat_wake", "goal_review", "task_review"]).optional(),
|
||||
workLanguage: z.enum(["en", "zh"]).nullable().optional(),
|
||||
}) satisfies z.ZodType<C.CreateTaskInput>,
|
||||
updateTask: z.strictObject({
|
||||
orgId: id, taskId: id, title: z.string(), description: z.string(), assigneeAgentId: nullableId, risk: z.enum(RISK_LEVEL),
|
||||
priority: z.enum(TASK_PRIORITY), parentTaskId: nullableId, projectId: nullableId, goalId: nullableId, workspaceId: nullableId,
|
||||
requiresFilesystem: z.boolean(), writingMode: z.enum(WRITING_MODE),
|
||||
requiresFilesystem: z.boolean(), writingMode: z.enum(WRITING_MODE), workLanguage: z.enum(["en", "zh"]).nullable(),
|
||||
}) satisfies z.ZodType<C.UpdateTaskInput>,
|
||||
deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.DeleteTaskInput>,
|
||||
setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType<C.SetTaskBlockedInput>,
|
||||
@@ -61,7 +62,7 @@ export const organizationContracts = {
|
||||
deleteWorkspace: z.strictObject({ orgId: id, workspaceId: id }),
|
||||
updateOrganization: z.strictObject({
|
||||
orgId: id, name: z.string(), description: z.string(), mission: z.string(), goal: z.string(),
|
||||
executionMode: z.enum(["ai_ceo", "ai_cofounder"]), autoApproveHires: z.boolean(), reviewDeliverables: z.boolean(),
|
||||
executionMode: z.enum(["ai_ceo", "ai_cofounder"]), autoApproveHires: z.boolean(), reviewDeliverables: z.boolean(), workLanguage: z.enum(["en", "zh"]),
|
||||
}) satisfies z.ZodType<C.UpdateOrganizationInput>,
|
||||
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
|
||||
createProject: z.strictObject({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Agents' working language belongs to the company (and optionally a task), not to the app's display language; companies keep what they had.
|
||||
export const workLanguageSchema = `
|
||||
alter table organizations add column work_language text not null default 'en' check (work_language in ('en', 'zh'));
|
||||
update organizations set work_language = coalesce((select case when language = 'zh' then 'zh' else 'en' end from app_state where id = 1), 'en');
|
||||
alter table tasks add column work_language text check (work_language is null or work_language in ('en', 'zh'));
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (104, 'work_language', datetime('now'));
|
||||
`;
|
||||
@@ -102,6 +102,7 @@ import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests";
|
||||
import { taskBlockedSchema } from "./migrations/101-task-blocked";
|
||||
import { goalDueDateSchema } from "./migrations/102-goal-due-date";
|
||||
import { workVerdictsSchema } from "./migrations/103-work-verdicts";
|
||||
import { workLanguageSchema } from "./migrations/104-work-language";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -213,6 +214,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 101, name: "task_blocked", sql: taskBlockedSchema },
|
||||
{ version: 102, name: "goal_due_date", sql: goalDueDateSchema },
|
||||
{ version: 103, name: "work_verdicts", sql: workVerdictsSchema },
|
||||
{ version: 104, name: "work_language", sql: workLanguageSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WritingMode } from "../../shared/domain";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE, type RiskLevel, type WorkLanguage, type WritingMode } from "../../shared/domain";
|
||||
import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service";
|
||||
import type { OrganizationService } from "./organization-service";
|
||||
import { buildTaskHandoff, HANDOFF_TOOL_PROPERTIES } from "./agent-task-handoff";
|
||||
@@ -364,6 +364,8 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord
|
||||
priority: TASK_PRIORITY.NORMAL,
|
||||
requiresFilesystem: args.requires_filesystem === true,
|
||||
writingMode,
|
||||
// Delegated work keeps the language its parent task was set to.
|
||||
workLanguage: ctx.taskId ? (deps.db.prepare("select work_language from tasks where id = ?").get(ctx.taskId) as { work_language: WorkLanguage | null } | undefined)?.work_language ?? null : null,
|
||||
});
|
||||
deps.db.prepare("update tasks set handoff_json = ?, parent_task_id = ? where id = ?")
|
||||
.run(JSON.stringify(handoff), parentTaskId, taskId);
|
||||
|
||||
@@ -7,22 +7,22 @@ export function updateOrganizationProfile(db: DatabaseSync, input: UpdateOrganiz
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("The company needs a name.");
|
||||
const before = db.prepare(`
|
||||
select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables from organizations where id = ?
|
||||
`).get(input.orgId) as { name: string; description: string; mission: string; goal: string; execution_mode: string; auto_approve_hires: number; review_deliverables: number } | undefined;
|
||||
select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables, work_language from organizations where id = ?
|
||||
`).get(input.orgId) as { name: string; description: string; mission: string; goal: string; execution_mode: string; auto_approve_hires: number; review_deliverables: number; work_language: string } | undefined;
|
||||
if (!before) throw new Error("Organization was not found.");
|
||||
const next = {
|
||||
name, description: input.description.trim(), mission: input.mission.trim(), goal: input.goal.trim(),
|
||||
executionMode: input.executionMode, autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables,
|
||||
executionMode: input.executionMode, autoApproveHires: input.autoApproveHires, reviewDeliverables: input.reviewDeliverables, workLanguage: input.workLanguage,
|
||||
};
|
||||
const previous = {
|
||||
name: before.name, description: before.description, mission: before.mission, goal: before.goal,
|
||||
executionMode: before.execution_mode, autoApproveHires: before.auto_approve_hires === 1, reviewDeliverables: before.review_deliverables === 1,
|
||||
executionMode: before.execution_mode, autoApproveHires: before.auto_approve_hires === 1, reviewDeliverables: before.review_deliverables === 1, workLanguage: before.work_language,
|
||||
};
|
||||
const changed = (Object.keys(next) as Array<keyof typeof next>).filter((key) => next[key] !== previous[key]);
|
||||
db.prepare(`
|
||||
update organizations set name = ?, description = ?, mission = ?, goal = ?, execution_mode = ?, auto_approve_hires = ?, review_deliverables = ?
|
||||
update organizations set name = ?, description = ?, mission = ?, goal = ?, execution_mode = ?, auto_approve_hires = ?, review_deliverables = ?, work_language = ?
|
||||
where id = ?
|
||||
`).run(next.name, next.description, next.mission, next.goal, next.executionMode, next.autoApproveHires ? 1 : 0, next.reviewDeliverables ? 1 : 0, input.orgId);
|
||||
`).run(next.name, next.description, next.mission, next.goal, next.executionMode, next.autoApproveHires ? 1 : 0, next.reviewDeliverables ? 1 : 0, next.workLanguage, input.orgId);
|
||||
console.info(`[organization] ${input.orgId} updated: ${changed.join(", ") || "nothing changed"}`);
|
||||
new AuditService(db).record({ orgId: input.orgId, entityType: "organization", entityId: input.orgId, actorType: "employee", actorId: null, action: "updated", payload: { changed, ...Object.fromEntries(changed.map((key) => [key, next[key]])) } });
|
||||
}
|
||||
@@ -239,9 +239,9 @@ export class OrganizationService {
|
||||
this.db.prepare(`
|
||||
insert into tasks(
|
||||
id, org_id, title, description, status, assignee_agent_id, risk,
|
||||
project_id, goal_id, workspace_id, priority, requires_filesystem, writing_mode, origin, created_at, updated_at
|
||||
project_id, goal_id, workspace_id, priority, requires_filesystem, writing_mode, origin, work_language, created_at, updated_at
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
@@ -257,6 +257,7 @@ export class OrganizationService {
|
||||
input.requiresFilesystem ? 1 : 0,
|
||||
input.writingMode ?? WRITING_MODE.CONVERSATIONAL,
|
||||
input.origin ?? null,
|
||||
input.workLanguage ?? null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WorkLanguage } from "../../shared/domain";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { BindSkillInput, ImportSkillDirectoryInput, ResolveAgentSkillsInput, RunSkillTestsInput } from "../../shared/commands";
|
||||
@@ -15,13 +16,7 @@ function slugify(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* House-style humanizer skills, one per app language. Every agent carries
|
||||
* the variant matching the current UI language on every run, CEO included.
|
||||
* They are a resolution rule, not a binding row, so no creation path — hire
|
||||
* tool, onboarding, tests, raw SQL — can produce an agent without one.
|
||||
* Both variants are protected from grant/revoke.
|
||||
*/
|
||||
/** House-style humanizer skills, one per working language: every agent carries its task's variant by rule, never by grant. */
|
||||
export const HUMANIZER_SLUG_EN = "humanizer";
|
||||
export const HUMANIZER_SLUG_ZH = "humanizer-zh";
|
||||
export const FORCED_SKILL_SLUGS = [HUMANIZER_SLUG_EN, HUMANIZER_SLUG_ZH] as const;
|
||||
@@ -243,21 +238,17 @@ export class SkillService {
|
||||
* agent carries the forced house-style skill once the catalogue holds a
|
||||
* language variant, plus whatever bindings the CEO granted.
|
||||
*/
|
||||
hasRuntimeSkills(orgId: string, agentId: string, houseStyle: boolean): boolean {
|
||||
if (houseStyle && this.forcedSkillRows(orgId).length > 0) return true;
|
||||
hasRuntimeSkills(orgId: string, agentId: string, houseStyle: boolean, language: WorkLanguage): boolean {
|
||||
if (houseStyle && this.forcedSkillRows(orgId, language).length > 0) return true;
|
||||
return Boolean(this.db.prepare(`
|
||||
select sb.id from skill_bindings sb join skill_definitions sd on sd.id = sb.skill_id and sd.status = 'active'
|
||||
where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 limit 1
|
||||
`).get(orgId, agentId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The single forced house-style skill for the current app language:
|
||||
* queries both humanizer variants and keeps the best match by preference
|
||||
* order (language-matched first, other variant as fallback).
|
||||
*/
|
||||
private forcedSkillRows(orgId: string): RuntimeSkillRow[] {
|
||||
const candidates = forcedSkillSlugCandidates(this.appLanguage());
|
||||
/** The forced house-style skill for the working language, falling back to the other variant. */
|
||||
private forcedSkillRows(orgId: string, language: WorkLanguage): RuntimeSkillRow[] {
|
||||
const candidates = forcedSkillSlugCandidates(language);
|
||||
const rows = this.db.prepare(`
|
||||
select
|
||||
sd.id as skill_id,
|
||||
@@ -280,11 +271,6 @@ export class SkillService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private appLanguage(): string {
|
||||
const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
|
||||
return row?.language === "zh" ? "zh" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes the exact skill set this run carries: forced house-style skills
|
||||
* first (identical for everyone, CEO included), then the agent's enabled
|
||||
@@ -293,7 +279,7 @@ export class SkillService {
|
||||
* prompt instead of silently overflowing the model's context.
|
||||
*/
|
||||
resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string {
|
||||
const forcedRows = input.houseStyle ? this.forcedSkillRows(input.orgId) : [];
|
||||
const forcedRows = input.houseStyle ? this.forcedSkillRows(input.orgId, input.language) : [];
|
||||
const boundRows = this.db.prepare(`
|
||||
select
|
||||
sb.skill_id as skill_id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { taskWorkLanguage } from "./work-language";
|
||||
import { closeChatWakeTask } from "./chat-wake-close";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { RunTaskInput } from "../../shared/commands";
|
||||
import { TASK_STATUS, WRITING_MODE } from "../../shared/domain";
|
||||
import { TASK_STATUS, WRITING_MODE, type WorkLanguage } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { ProviderService } from "./provider-service";
|
||||
@@ -186,7 +187,7 @@ export class TaskExecutionService {
|
||||
.run(effectiveModel(agent.model_id, provider.model), provider.credential_id, runId);
|
||||
}
|
||||
const runModel = pinned ?? effectiveModel(agent.model_id, provider.model);
|
||||
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL);
|
||||
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: runModel }, task.writing_mode === WRITING_MODE.CONVERSATIONAL, taskWorkLanguage(this.db, orgId, task.id));
|
||||
// Watermark for reply delivery and mid-run message polling.
|
||||
const runStartedAt = new Date().toISOString();
|
||||
const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson);
|
||||
@@ -207,7 +208,7 @@ export class TaskExecutionService {
|
||||
const connectorNames = new ConnectorGrantsService(this.db)
|
||||
.toolkitsForAgent(orgId, agent.id)
|
||||
.map(connectorDisplayName);
|
||||
const language = this.appLanguage();
|
||||
const language = taskWorkLanguage(this.db, orgId, task.id);
|
||||
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
|
||||
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
|
||||
const externalTools = loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
|
||||
@@ -426,10 +427,6 @@ export class TaskExecutionService {
|
||||
return row.name;
|
||||
}
|
||||
|
||||
private appLanguage(): "en" | "zh" {
|
||||
const row = this.db.prepare("select language from app_state where id = 1").get() as { language: string } | undefined;
|
||||
return row?.language === "zh" ? "zh" : "en";
|
||||
}
|
||||
|
||||
private completeRun(orgId: string, runId: string, providerRequestId: string | null, checkpoint: boolean): void {
|
||||
this.db.prepare(`
|
||||
@@ -477,9 +474,9 @@ export class TaskExecutionService {
|
||||
`).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, failureKind, runId, orgId);
|
||||
}
|
||||
|
||||
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean): SkillRuntimeSnapshotRow | null {
|
||||
if (!this.skills.hasRuntimeSkills(orgId, agentId, houseStyle)) return null;
|
||||
const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens, taskText, runModel, houseStyle });
|
||||
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean, language: WorkLanguage): SkillRuntimeSnapshotRow | null {
|
||||
if (!this.skills.hasRuntimeSkills(orgId, agentId, houseStyle, language)) return null;
|
||||
const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens, taskText, runModel, houseStyle, language });
|
||||
const snapshot = this.db.prepare(`
|
||||
select id, compiled_prompt, tools_manifest, source_revision_ids
|
||||
from skill_runtime_snapshots
|
||||
|
||||
@@ -55,12 +55,12 @@ export class TaskMutations {
|
||||
}
|
||||
const result = this.db.prepare(`
|
||||
update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?,
|
||||
project_id = ?, goal_id = ?, workspace_id = ?, requires_filesystem = ?, writing_mode = ?, updated_at = ?,
|
||||
project_id = ?, goal_id = ?, workspace_id = ?, requires_filesystem = ?, writing_mode = ?, work_language = ?, updated_at = ?,
|
||||
stalled_reason = null, enqueue_error = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs)
|
||||
where id = ? and org_id = ?
|
||||
`).run(
|
||||
input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId,
|
||||
input.projectId, input.goalId, input.workspaceId, input.requiresFilesystem ? 1 : 0, input.writingMode, now, input.taskId, input.orgId,
|
||||
input.projectId, input.goalId, input.workspaceId, input.requiresFilesystem ? 1 : 0, input.writingMode, input.workLanguage, now, input.taskId, input.orgId,
|
||||
);
|
||||
if (result.changes === 0) throw new Error("Task was not found.");
|
||||
this.requestApproval(input.orgId, input.taskId);
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface AgentInstructionContext {
|
||||
hasReports: boolean;
|
||||
/** Display names of the connected toolkits this agent carries in this run. */
|
||||
connectorNames: string[];
|
||||
/** The operator's app language; agents speak it back. */
|
||||
/** The task's working language (its own, else the company's). */
|
||||
language: "en" | "zh";
|
||||
}
|
||||
|
||||
@@ -112,9 +112,9 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
|
||||
roleVoice(input),
|
||||
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
|
||||
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes company documents (PRDs, specs, memos, reports) and list_documents, read_document, update_document find and revise them, search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
|
||||
...(input.language === "zh"
|
||||
? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"]
|
||||
: []),
|
||||
input.language === "zh"
|
||||
? "公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"
|
||||
: "The working language is English: write every message, task title and description, document and deliverable in English (code, commands and API names stay as they are), even when the material you read is in another language.",
|
||||
capabilityBriefing(input),
|
||||
"Talk like a person on a team, not a status bot: address teammates by @name, say what you genuinely think, ask when you are unsure, and reply when someone asks you something. Before starting work, check your messages with read_messages to see if anyone needs you. Post where the work lives — 'engineering', 'product', or otherwise 'general' — and keep a back-and-forth in one thread by passing the thread_id a previous send_message returned. Hand work to a teammate with /handoff @Name and the file_id write_deliverable gives you.",
|
||||
].join("\n");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { WorkLanguage } from "../../shared/domain";
|
||||
|
||||
/** The language agents work in on a task: the task's own, else its company's; the app's display language plays no part. */
|
||||
export function taskWorkLanguage(db: DatabaseSync, orgId: string, taskId: string): WorkLanguage {
|
||||
const row = db.prepare(`
|
||||
select coalesce(t.work_language, o.work_language) as language
|
||||
from tasks t join organizations o on o.id = t.org_id
|
||||
where t.id = ? and t.org_id = ?
|
||||
`).get(taskId, orgId) as { language: WorkLanguage } | undefined;
|
||||
if (!row) throw new Error("Task was not found.");
|
||||
return row.language;
|
||||
}
|
||||
@@ -695,6 +695,11 @@ export const zh: Record<string, string> = {
|
||||
"Zero data retention switched background off.": "零数据保留已关闭后台执行。",
|
||||
"Resets {date} (UTC)": "{date} 重置(UTC)",
|
||||
"Unsaved": "未保存",
|
||||
"Working language": "工作语言",
|
||||
"Company default ({language})": "公司默认({language})",
|
||||
"Agents' working language": "Agent 工作语言",
|
||||
"Agents write messages, tasks, documents and deliverables in it. The display language does not change it, and a task can set its own.": "Agent 用它撰写消息、任务、文档和交付物。显示语言不会改变它,单个任务也可以单独设置。",
|
||||
"Changes only what you see; agents keep the company's working language.": "只改变界面显示;Agent 仍使用公司的工作语言。",
|
||||
"Disconnect {label}?": "断开 {label}?",
|
||||
"Checking what this affects…": "正在检查影响范围…",
|
||||
"Agents on this provider stop working until it is reconnected or they move to another provider: {names}.": "使用该 Provider 的 Agent 将停止工作,直到重新连接或改用其他 Provider:{names}。",
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { AppSnapshot, RiskLevel, TaskPriority, WritingMode } from "../../..
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../../shared/domain";
|
||||
import type { SnapshotActions } from "../app/useSnapshot";
|
||||
import { t } from "../i18n/i18n";
|
||||
import { WRITING_OPTIONS } from "./work/task-helpers";
|
||||
import { WRITING_OPTIONS, readWorkLanguage } from "./work/task-helpers";
|
||||
import { WorkLanguageField } from "./work/WorkLanguageField";
|
||||
import { requireActiveOrganization } from "./types";
|
||||
|
||||
const RISK_OPTIONS: ReadonlyArray<{ value: RiskLevel; label: string }> = [
|
||||
{ value: RISK_LEVEL.LOW, label: t("Low") },
|
||||
@@ -61,6 +63,7 @@ export function NewTaskDialog({
|
||||
priority: value(form, "priority") as TaskPriority,
|
||||
requiresFilesystem: form.get("requiresFilesystem") === "on",
|
||||
writingMode: value(form, "writingMode") as WritingMode,
|
||||
workLanguage: readWorkLanguage(form),
|
||||
})).then((result) => {
|
||||
if (result) onClose();
|
||||
});
|
||||
@@ -109,6 +112,7 @@ export function NewTaskDialog({
|
||||
{WRITING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<WorkLanguageField companyLanguage={requireActiveOrganization(snapshot).workLanguage} defaultValue={null} />
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="requiresFilesystem" /> {t("Requires workspace access")}
|
||||
</label>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ListView } from "./work/ListView";
|
||||
import { ProgressView } from "./work/ProgressView";
|
||||
import { isBusinessTask, latestTaskRun, nextStatus, prettyRunState, providerFor, runBlocker, runTone } from "./work/task-helpers";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, type Focus } from "./types";
|
||||
import { activeOrgId, requireActiveOrganization, type Focus } from "./types";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
type View = "board" | "list" | "progress";
|
||||
@@ -166,6 +166,7 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {
|
||||
<TaskDetailDialog
|
||||
key={detailTask?.id ?? "none"}
|
||||
task={detailTask}
|
||||
companyLanguage={requireActiveOrganization(snapshot).workLanguage}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
goals={goals}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Field } from "../../shared/ui";
|
||||
import { Dialog } from "../../shared/Dialog";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import { WORK_LANGUAGE_NAMES } from "../work/task-helpers";
|
||||
|
||||
/** Changes the company's profile and how it runs after onboarding; requests already waiting keep their state. */
|
||||
export function CompanyDialog({ organization, open, actions, onClose }: { organization: OrganizationRecord; open: boolean; actions: SnapshotActions; onClose: () => void }) {
|
||||
@@ -22,6 +23,7 @@ export function CompanyDialog({ organization, open, actions, onClose }: { organi
|
||||
executionMode: value("executionMode") === "ai_cofounder" ? "ai_cofounder" : "ai_ceo",
|
||||
autoApproveHires: form.get("autoApproveHires") === "on",
|
||||
reviewDeliverables: form.get("reviewDeliverables") === "on",
|
||||
workLanguage: value("workLanguage") === "zh" ? "zh" : "en",
|
||||
})).then(onClose, (reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
|
||||
};
|
||||
return (
|
||||
@@ -36,6 +38,13 @@ export function CompanyDialog({ organization, open, actions, onClose }: { organi
|
||||
<option value="ai_cofounder">{t("AI Co-Founder: asks before risky tasks")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Agents' working language")}>
|
||||
<select className="input" name="workLanguage" defaultValue={organization.workLanguage}>
|
||||
<option value="en">{WORK_LANGUAGE_NAMES.en}</option>
|
||||
<option value="zh">{WORK_LANGUAGE_NAMES.zh}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Agents write messages, tasks, documents and deliverables in it. The display language does not change it, and a task can set its own.")}</p>
|
||||
<label className="checkbox-row"><input type="checkbox" name="autoApproveHires" defaultChecked={organization.autoApproveHires} /> {t("Auto-approve hires")}</label>
|
||||
<label className="checkbox-row"><input type="checkbox" name="reviewDeliverables" defaultChecked={organization.reviewDeliverables} /> {t("Review deliverables before they are written")}</label>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Changes apply to work from now on; requests already waiting keep their state.")}</div>
|
||||
|
||||
@@ -114,6 +114,7 @@ export function WorkspacePanel({
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Changes only what you see; agents keep the company's working language.")}</span>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import type {
|
||||
TaskRunRecord,
|
||||
WorkspaceRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { TASK_STATUS, type TaskDeleteImpact, type TaskStatus, type WritingMode } from "../../../../shared/domain";
|
||||
import { TASK_STATUS, type TaskDeleteImpact, type TaskStatus, type WorkLanguage, type WritingMode } from "../../../../shared/domain";
|
||||
import { auditEventLabel, formatRelative } from "../types";
|
||||
import { WRITING_OPTIONS, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers";
|
||||
import { WorkLanguageField } from "./WorkLanguageField";
|
||||
import { WRITING_OPTIONS, readWorkLanguage, descendantIds, nextStatus, prettyRunState, prettyStatus, providerFor, runBlocker, runsForTask, runTone } from "./task-helpers";
|
||||
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||
import type { Focus, PageProps } from "../types";
|
||||
import { t } from "../../i18n/i18n";
|
||||
@@ -28,6 +29,7 @@ const TABS: Array<{ id: Tab; label: string }> = [
|
||||
|
||||
export function TaskDetailDialog({
|
||||
task,
|
||||
companyLanguage,
|
||||
agents,
|
||||
projects,
|
||||
goals,
|
||||
@@ -49,6 +51,7 @@ export function TaskDetailDialog({
|
||||
navigate,
|
||||
}: {
|
||||
task: TaskRecord | null;
|
||||
companyLanguage: WorkLanguage;
|
||||
agents: AgentRecord[];
|
||||
projects: ProjectRecord[];
|
||||
goals: GoalRecord[];
|
||||
@@ -114,6 +117,7 @@ export function TaskDetailDialog({
|
||||
workspaceId: val("workspaceId") || null,
|
||||
requiresFilesystem: form.get("requiresFilesystem") === "on",
|
||||
writingMode: val("writingMode") as WritingMode,
|
||||
workLanguage: readWorkLanguage(form),
|
||||
})).then((r) => { if (r) setEditing(false); }, (error: unknown) => setEditError(error instanceof Error ? error.message : String(error)));
|
||||
};
|
||||
|
||||
@@ -203,6 +207,7 @@ export function TaskDetailDialog({
|
||||
{editing && (
|
||||
<EditOverlay
|
||||
task={task}
|
||||
companyLanguage={companyLanguage}
|
||||
agents={agents}
|
||||
allTasks={allTasks}
|
||||
projects={projects}
|
||||
@@ -314,8 +319,8 @@ function ActivityTab({ audit, approvals, onOpenApproval }: { audit: AuditEventRe
|
||||
);
|
||||
}
|
||||
|
||||
function EditOverlay({ task, agents, allTasks, projects, goals, workspaces, error, onSubmit, onCancel }: {
|
||||
task: TaskRecord; agents: AgentRecord[]; allTasks: TaskRecord[];
|
||||
function EditOverlay({ task, companyLanguage, agents, allTasks, projects, goals, workspaces, error, onSubmit, onCancel }: {
|
||||
task: TaskRecord; companyLanguage: WorkLanguage; agents: AgentRecord[]; allTasks: TaskRecord[];
|
||||
projects: ProjectRecord[]; goals: GoalRecord[]; workspaces: WorkspaceRecord[]; error: string | null;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void; onCancel: () => void;
|
||||
}) {
|
||||
@@ -374,6 +379,7 @@ function EditOverlay({ task, agents, allTasks, projects, goals, workspaces, erro
|
||||
{WRITING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<WorkLanguageField companyLanguage={companyLanguage} defaultValue={task.workLanguage} />
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="requiresFilesystem" defaultChecked={task.requiresFilesystem} /> {t("Requires workspace access")}
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { WorkLanguage } from "../../../../shared/domain";
|
||||
import { Field } from "../../shared/ui";
|
||||
import { t } from "../../i18n/i18n";
|
||||
import { WORK_LANGUAGE_NAMES } from "./task-helpers";
|
||||
|
||||
/** A task's working language; blank follows the company's, named so the user sees what blank means. */
|
||||
export function WorkLanguageField({ companyLanguage, defaultValue }: { companyLanguage: WorkLanguage; defaultValue: WorkLanguage | null }) {
|
||||
return (
|
||||
<Field label={t("Working language")}>
|
||||
<select className="input" name="workLanguage" defaultValue={defaultValue ?? ""}>
|
||||
<option value="">{t("Company default ({language})", { language: WORK_LANGUAGE_NAMES[companyLanguage] })}</option>
|
||||
<option value="en">{WORK_LANGUAGE_NAMES.en}</option>
|
||||
<option value="zh">{WORK_LANGUAGE_NAMES.zh}</option>
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,22 @@ import {
|
||||
type TaskRecord,
|
||||
type TaskRunRecord,
|
||||
type TaskStatus,
|
||||
type WorkLanguage,
|
||||
type WritingMode,
|
||||
} from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** Each working language in its own script, so it reads right whatever the display language. */
|
||||
export const WORK_LANGUAGE_NAMES: Record<WorkLanguage, string> = { en: "English", zh: "简体中文" };
|
||||
|
||||
/** The task form's working language: blank follows the company. */
|
||||
export function readWorkLanguage(form: FormData): WorkLanguage | null {
|
||||
const value = String(form.get("workLanguage") ?? "");
|
||||
if (value === "") return null;
|
||||
if (value === "en" || value === "zh") return value;
|
||||
throw new Error(`Unknown working language "${value}".`);
|
||||
}
|
||||
|
||||
/** The writing modes a task can ask for, as the create and edit forms offer them. */
|
||||
export const WRITING_OPTIONS: ReadonlyArray<{ value: WritingMode; label: string }> = [
|
||||
{ value: WRITING_MODE.CONVERSATIONAL, label: t("Conversational (house style)") },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus, UiLanguage, UiTheme, WritingMode } from "../domain";
|
||||
import type { ApprovalState, IdentitySource, PrincipalType, RiskLevel, RoleKey, TaskPriority, TaskStatus, UiLanguage, UiTheme, WorkLanguage, WritingMode } from "../domain";
|
||||
|
||||
export interface SetActiveOrganizationInput {
|
||||
orgId: string;
|
||||
@@ -92,6 +92,8 @@ export interface CreateTaskInput {
|
||||
requiresFilesystem: boolean;
|
||||
/** Defaults to conversational. */
|
||||
writingMode?: WritingMode;
|
||||
/** Null or omitted follows the company's working language. */
|
||||
workLanguage?: WorkLanguage | null;
|
||||
/** Provenance: 'chat_wake' for mention-wake vehicles, 'goal_review' for CEO goal checkpoints, 'task_review' for a manager's review of work, omitted for plain work. */
|
||||
origin?: "chat_wake" | "goal_review" | "task_review";
|
||||
}
|
||||
@@ -121,6 +123,7 @@ export interface UpdateTaskInput {
|
||||
workspaceId: string | null;
|
||||
requiresFilesystem: boolean;
|
||||
writingMode: WritingMode;
|
||||
workLanguage: WorkLanguage | null;
|
||||
}
|
||||
|
||||
export interface SetTaskBlockedInput {
|
||||
@@ -154,6 +157,7 @@ export interface UpdateOrganizationInput {
|
||||
executionMode: "ai_ceo" | "ai_cofounder";
|
||||
autoApproveHires: boolean;
|
||||
reviewDeliverables: boolean;
|
||||
workLanguage: WorkLanguage;
|
||||
}
|
||||
|
||||
export interface SetGoalDueDateInput {
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface ResolveAgentSkillsInput {
|
||||
runModel: { providerKey: string; model: string | null };
|
||||
/** False on technical and structured tasks, which skip the house-style skill. */
|
||||
houseStyle: boolean;
|
||||
/** Picks the house-style variant: the task's working language. */
|
||||
language: "en" | "zh";
|
||||
}
|
||||
|
||||
export interface RunSkillTestsInput {
|
||||
|
||||
@@ -33,6 +33,9 @@ export type ProviderAuthType = ValueOf<typeof PROVIDER_AUTH_TYPE>;
|
||||
export type SocialPlatform = ValueOf<typeof SOCIAL_PLATFORM>;
|
||||
export type FileScanState = ValueOf<typeof FILE_SCAN_STATE>;
|
||||
|
||||
/** The language agents write in; separate from the app's display language. */
|
||||
export type WorkLanguage = "en" | "zh";
|
||||
|
||||
export interface OrganizationRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -45,6 +48,8 @@ export interface OrganizationRecord {
|
||||
/** The provider and model that audit goal completion, never the executor's own; null until the board sets them. */
|
||||
auditProviderId: string | null;
|
||||
auditModelId: string | null;
|
||||
/** The language its agents work in unless a task sets its own. */
|
||||
workLanguage: WorkLanguage;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -134,6 +139,8 @@ export interface TaskRecord {
|
||||
parentTaskId: string | null;
|
||||
requiresFilesystem: boolean;
|
||||
writingMode: WritingMode;
|
||||
/** Overrides the company's working language for this task; null follows the company. */
|
||||
workLanguage: WorkLanguage | null;
|
||||
/** Set when the task spent its run budget without reaching done. */
|
||||
stalledReason: string | null;
|
||||
/** Why the heartbeat last failed to queue the task; cleared once it queues. */
|
||||
|
||||
@@ -80,6 +80,7 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
const editCompany = async (mode: string, autoHires: boolean) => {
|
||||
await page.getByRole("button", { name: "Edit company" }).click();
|
||||
await page.getByLabel("Who runs the work").selectOption(mode);
|
||||
await expect(page.locator(".modal select[name=workLanguage]")).toHaveValue("en");
|
||||
await page.getByLabel("Auto-approve hires").setChecked(autoHires);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
};
|
||||
@@ -138,14 +139,17 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await taskDialog.getByRole("button", { name: "Edit" }).click();
|
||||
await taskDialog.getByLabel("Writing").selectOption("technical");
|
||||
await taskDialog.getByLabel("Requires workspace access").check();
|
||||
// The working language is the task's own; blank names the company's.
|
||||
await expect(taskDialog.locator("select[name=workLanguage] option").first()).toHaveText("Company default (English)");
|
||||
await taskDialog.locator("select[name=workLanguage]").selectOption("zh");
|
||||
await taskDialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(taskDialog.getByRole("heading", { name: "Edit task" })).toHaveCount(0);
|
||||
const edited = await page.evaluate(async () => {
|
||||
const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { tasks: Array<{ title: string; writingMode: string; requiresFilesystem: boolean }> } }> } }).agentCompany;
|
||||
const api = (window as unknown as { agentCompany: { loadSnapshot: () => Promise<{ ok: true; data: { tasks: Array<{ title: string; writingMode: string; requiresFilesystem: boolean; workLanguage: string | null }> } }> } }).agentCompany;
|
||||
const task = (await api.loadSnapshot()).data.tasks.find((item) => item.title === "Operationalize goal: Ship the FlowPilot MVP.")!;
|
||||
return [task.writingMode, task.requiresFilesystem];
|
||||
return [task.writingMode, task.requiresFilesystem, task.workLanguage];
|
||||
});
|
||||
expect(edited).toEqual(["technical", true]);
|
||||
expect(edited).toEqual(["technical", true, "zh"]);
|
||||
// A pending request opens for review: read it in full and decide with notes; nothing there pretends to edit it.
|
||||
await taskDialog.getByRole("button", { name: "Close" }).click();
|
||||
await page.evaluate(async () => {
|
||||
@@ -378,7 +382,7 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
const child = (await api.loadSnapshot()).data.tasks.find((task) => task.title === "Child step")!;
|
||||
await api.updateTask({
|
||||
orgId: before.activeOrgId, taskId: child.id, title: "Child step", description: "d", assigneeAgentId: null, risk: "low", priority: "normal",
|
||||
parentTaskId: parent.id, projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false, writingMode: "conversational",
|
||||
parentTaskId: parent.id, projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false, writingMode: "conversational", workLanguage: null,
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Work" }).click();
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("collaboration integration flow", () => {
|
||||
revisionId = skills.importDirectory({ orgId, directoryPath: createSkillDirectory(), versionSemver: "1.0.0" });
|
||||
skillId = (connection.db.prepare("select skill_id from skill_revisions where id = ?").get(revisionId) as { skill_id: string }).skill_id;
|
||||
skills.bind({ orgId, agentId, skillId, revisionId });
|
||||
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: `run_${randomUUID().replaceAll("-", "")}`, maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true });
|
||||
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: `run_${randomUUID().replaceAll("-", "")}`, maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" });
|
||||
});
|
||||
|
||||
const state = snapshot.load();
|
||||
|
||||
@@ -173,11 +173,21 @@ describe("agent instruction wiring", () => {
|
||||
expect(capturedInstructions).toContain("Connected external services you carry in this run: GitHub");
|
||||
expect(capturedInstructions).toContain("list_connectors");
|
||||
|
||||
// Switching the app language redirects the whole company to Chinese.
|
||||
const rerun = async () => {
|
||||
connection.db.prepare("update task_runs set state = 'failed' where org_id = ?").run(orgId);
|
||||
await execution.runTask({ orgId, taskId });
|
||||
return capturedInstructions;
|
||||
};
|
||||
// The display language changes only what the operator sees.
|
||||
organization.updateUiPreferences({ language: "zh" });
|
||||
connection.db.prepare("update task_runs set state = 'failed' where org_id = ?").run(orgId);
|
||||
await execution.runTask({ orgId, taskId });
|
||||
expect(capturedInstructions).toContain("公司的工作语言是简体中文");
|
||||
expect(await rerun()).toContain("The working language is English");
|
||||
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
|
||||
// The company's working language redirects its agents, and a task can keep its own.
|
||||
connection.db.prepare("update organizations set work_language = 'zh' where id = ?").run(orgId);
|
||||
expect(await rerun()).toContain("公司的工作语言是简体中文");
|
||||
connection.db.prepare("update tasks set work_language = 'en' where id = ?").run(taskId);
|
||||
expect(await rerun()).toContain("The working language is English");
|
||||
expect(capturedInstructions).not.toContain("公司的工作语言是简体中文");
|
||||
});
|
||||
|
||||
test("a new department head's task prompt tells them to staff their department, not to work as an IC", async () => {
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("built-in skills", () => {
|
||||
h.skills.bind({ orgId: h.orgId, agentId, skillId: skill.id, revisionId: skill.revision_id });
|
||||
});
|
||||
const prompt = (runId: string) => {
|
||||
const id = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
const id = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" }));
|
||||
return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(id) as { compiled_prompt: string }).compiled_prompt;
|
||||
};
|
||||
expect(prompt("run_before")).toContain("# Skill: Backend Architect");
|
||||
@@ -265,7 +265,7 @@ describe("built-in skills", () => {
|
||||
expect(hired).toContain("Unknown skills skipped: no-such-skill");
|
||||
|
||||
const danaId = (h.connection.db.prepare("select id from agents where org_id = ? and name = 'Dana Lee'").get(h.orgId) as { id: string }).id;
|
||||
const danaSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
const danaSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" }));
|
||||
const danaPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(danaSnapshot) as { compiled_prompt: string }).compiled_prompt;
|
||||
expect(danaPrompt.indexOf("# Skill: humanizer")).toBe(0);
|
||||
expect(danaPrompt).toContain("# Skill: Backend Architect");
|
||||
@@ -274,15 +274,15 @@ describe("built-in skills", () => {
|
||||
expect(danaPrompt.length).toBeLessThan(SKILL_TOKENS * 3 + 5_000);
|
||||
|
||||
// The CEO carries humanizer too - forced by resolution, not by binding.
|
||||
expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, true)).toBe(true);
|
||||
const ceoSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: ceoId, runId: "run_ceo", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, true, "en")).toBe(true);
|
||||
const ceoSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: ceoId, runId: "run_ceo", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" }));
|
||||
const ceoPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(ceoSnapshot) as { compiled_prompt: string }).compiled_prompt;
|
||||
expect(ceoPrompt).toContain("# Skill: humanizer");
|
||||
expect(ceoPrompt).not.toContain("# Skill: Backend Architect");
|
||||
|
||||
// Technical and structured work drops house style; a CEO without grants then carries nothing.
|
||||
expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, false)).toBe(false);
|
||||
const technicalSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana_spec", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: false }));
|
||||
expect(h.skills.hasRuntimeSkills(h.orgId, ceoId, false, "en")).toBe(false);
|
||||
const technicalSnapshot = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId: danaId, runId: "run_dana_spec", maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: false, language: "en" }));
|
||||
const technicalPrompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(technicalSnapshot) as { compiled_prompt: string }).compiled_prompt;
|
||||
expect(technicalPrompt).not.toContain("# Skill: humanizer");
|
||||
expect(technicalPrompt.indexOf("# Skill: Backend Architect")).toBe(0);
|
||||
@@ -295,7 +295,7 @@ describe("built-in skills", () => {
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("the forced humanizer follows the app language, with cross-language fallback", () => {
|
||||
test("the forced humanizer follows the working language, with cross-language fallback", () => {
|
||||
const h = harness();
|
||||
const staged = stageBuiltinSkillsLibrary(h.sourceRoot, h.libraryRoot);
|
||||
h.connection.transaction(() => importStagedSkills(h.skills, h.orgId, staged));
|
||||
@@ -306,28 +306,27 @@ describe("built-in skills", () => {
|
||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const promptFor = (runId: string) => {
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
const promptFor = (runId: string, language: "en" | "zh") => {
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens: SKILL_TOKENS, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language }));
|
||||
return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt;
|
||||
};
|
||||
|
||||
// English UI (default): the English humanizer, and only that one.
|
||||
const englishPrompt = promptFor("run_lang_en");
|
||||
// English work: the English humanizer, and only that one.
|
||||
const englishPrompt = promptFor("run_lang_en", "en");
|
||||
expect(englishPrompt).toContain("# Skill: humanizer");
|
||||
expect(englishPrompt).not.toContain("humanizer-zh");
|
||||
|
||||
// Chinese UI: the Chinese variant replaces it.
|
||||
h.connection.transaction(() => h.organization.updateUiPreferences({ language: "zh" }));
|
||||
const chinesePrompt = promptFor("run_lang_zh");
|
||||
// Chinese work: the Chinese variant replaces it.
|
||||
const chinesePrompt = promptFor("run_lang_zh", "zh");
|
||||
expect(chinesePrompt).toContain("# Skill: humanizer-zh");
|
||||
expect(chinesePrompt).not.toContain("# Skill: humanizer\n");
|
||||
|
||||
// Chinese UI against a catalogue without the zh variant falls back to
|
||||
// Chinese work against a catalogue without the zh variant falls back to
|
||||
// English house style instead of silently dropping it.
|
||||
h.connection.transaction(() => {
|
||||
h.connection.db.prepare("update skill_definitions set status = 'retired' where org_id = ? and slug = 'humanizer-zh'").run(h.orgId);
|
||||
});
|
||||
const fallbackPrompt = promptFor("run_lang_fallback");
|
||||
const fallbackPrompt = promptFor("run_lang_fallback", "zh");
|
||||
expect(fallbackPrompt).toContain("# Skill: humanizer");
|
||||
h.connection.close();
|
||||
});
|
||||
@@ -353,7 +352,7 @@ describe("built-in skills", () => {
|
||||
modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null,
|
||||
});
|
||||
});
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId: "run_tiny", maxTokens: 10, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId: "run_tiny", maxTokens: 10, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" }));
|
||||
const prompt = (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt;
|
||||
expect(prompt).not.toContain("# Skill:");
|
||||
expect(prompt).toContain("# Skills omitted for size");
|
||||
@@ -380,7 +379,7 @@ describe("built-in skills", () => {
|
||||
bindSkillSlugs({ db: h.connection.db, organization: h.organization, chat: new ChatService(h.connection.db) }, h.orgId, agentId, ["marketing-growth-hacker", "engineering-backend-architect"]);
|
||||
});
|
||||
const compile = (runId: string, maxTokens: number, taskText: string) => {
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens, taskText, runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true }));
|
||||
const snapshotId = h.connection.transaction(() => h.skills.resolveRuntimeSnapshot({ orgId: h.orgId, agentId, runId, maxTokens, taskText, runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" }));
|
||||
return (h.connection.db.prepare("select compiled_prompt from skill_runtime_snapshots where id = ?").get(snapshotId) as { compiled_prompt: string }).compiled_prompt;
|
||||
};
|
||||
const parts = compile("run_full", 1_000_000, "").split("\n\n---\n\n");
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("domain services", () => {
|
||||
skills.bind({ orgId, agentId, skillId: skill.id, revisionId });
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
skills.runTests({ orgId, revisionId });
|
||||
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true });
|
||||
skills.resolveRuntimeSnapshot({ orgId, agentId, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" });
|
||||
});
|
||||
|
||||
// Catalogue metadata ships in the snapshot; skill internals stay in the
|
||||
@@ -254,10 +254,10 @@ describe("domain services", () => {
|
||||
.get(orgA, "review-routes") as { id: string }).id;
|
||||
expect(() => skills.bind({ orgId: orgA, agentId: agentB, skillId, revisionId })).toThrow("Agent was not found.");
|
||||
skills.bind({ orgId: orgA, agentId: agentA, skillId, revisionId });
|
||||
const firstResolve = skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true });
|
||||
const firstResolve = skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" });
|
||||
// Idempotent per run: a recovered run reuses its frozen snapshot
|
||||
// instead of failing on the uniqueness guard.
|
||||
expect(skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true })).toBe(firstResolve);
|
||||
expect(skills.resolveRuntimeSnapshot({ orgId: orgA, agentId: agentA, runId: "run-review-1", maxTokens: 44_800, taskText: "", runModel: { providerKey: "openai", model: "gpt-5.4" }, houseStyle: true, language: "en" })).toBe(firstResolve);
|
||||
expect(connection.db.prepare("select count(*) as n from skill_runtime_snapshots where run_id = 'run-review-1'").get())
|
||||
.toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("execution modes", () => {
|
||||
}));
|
||||
const task = () => db.prepare("select * from tasks where id = ?").get(taskId) as unknown as Parameters<typeof executionApprovalProblem>[2];
|
||||
const taskApprovals = () => db.prepare("select id, state, details from approvals where source_entity_id = ? order by rowid").all(taskId) as Array<{ id: string; state: string; details: string }>;
|
||||
const edit = (description: string) => h.connection.transaction(() => h.deps.organization.updateTask({
|
||||
const edit = (description: string) => h.connection.transaction(() => h.deps.organization.updateTask({ workLanguage: null,
|
||||
orgId: h.orgId, taskId, title: "Wipe staging", description, assigneeAgentId: h.ceoId, risk: "high", priority: TASK_PRIORITY.NORMAL, parentTaskId: null,
|
||||
...currentLinks(db, taskId),
|
||||
}));
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("company settings after onboarding", () => {
|
||||
});
|
||||
const update = (change: Partial<{ name: string; executionMode: "ai_ceo" | "ai_cofounder" }>) => connection.transaction(() => organization.updateOrganization({
|
||||
orgId, name: "Acme Labs", description: "Analytics for CSMs.", mission: "Keep customers.", goal: "Ship the MVP.",
|
||||
executionMode: "ai_cofounder", autoApproveHires: false, reviewDeliverables: true, ...change,
|
||||
executionMode: "ai_cofounder", autoApproveHires: false, reviewDeliverables: true, workLanguage: "en", ...change,
|
||||
}));
|
||||
update({});
|
||||
expect(connection.db.prepare("select name, description, mission, goal, execution_mode, auto_approve_hires, review_deliverables from organizations where id = ?").get(orgId))
|
||||
@@ -23,7 +23,7 @@ describe("company settings after onboarding", () => {
|
||||
const audit = connection.db.prepare("select payload_json from audit_events where entity_type = 'organization' and action = 'updated' order by rowid desc limit 1").get() as { payload_json: string };
|
||||
expect(JSON.parse(audit.payload_json).changed).toEqual(expect.arrayContaining(["name", "executionMode", "reviewDeliverables"]));
|
||||
expect(() => update({ name: " " })).toThrow("The company needs a name.");
|
||||
expect(() => organization.updateOrganization({ orgId: "org_missing", name: "X", description: "", mission: "", goal: "", executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false }))
|
||||
expect(() => organization.updateOrganization({ orgId: "org_missing", name: "X", description: "", mission: "", goal: "", executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false, workLanguage: "en" }))
|
||||
.toThrow("Organization was not found.");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("skill compatibility", () => {
|
||||
skills.bind({ orgId, agentId, skillId: skill.id, revisionId });
|
||||
});
|
||||
const prompt = (runId: string, providerKey: string, model: string) => {
|
||||
const id = connection.transaction(() => skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens: 40_000, taskText: "", runModel: { providerKey, model }, houseStyle: true }));
|
||||
const id = connection.transaction(() => skills.resolveRuntimeSnapshot({ orgId, agentId, runId, maxTokens: 40_000, taskText: "", runModel: { providerKey, model }, houseStyle: true, language: "en" }));
|
||||
return connection.db.prepare("select compiled_prompt, source_revision_ids from skill_runtime_snapshots where id = ?").get(id) as { compiled_prompt: string; source_revision_ids: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -98,10 +98,10 @@ describe("skill grants", () => {
|
||||
expect(listSkills?.description).not.toContain("every skill automatically");
|
||||
expect(listSkills?.description).toContain("the CEO included");
|
||||
const skills = new SkillService(h.connection.db, "");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(false);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true, "en")).toBe(false);
|
||||
expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "CEO", skill: "release-gate" })))
|
||||
.toBe("CEO now has the Release Gate skill; it joins their next run.");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true, "en")).toBe(true);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
@@ -116,11 +116,11 @@ describe("skill grants", () => {
|
||||
expect(listing).toContain("Release Gate (release-gate): granted to Dana");
|
||||
|
||||
const skills = new SkillService(h.connection.db, "");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(true);
|
||||
|
||||
const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "release-gate" }));
|
||||
expect(revoked).toContain("no longer has the Release Gate skill");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(false);
|
||||
});
|
||||
|
||||
test("staff cannot hand out skills", () => {
|
||||
@@ -128,7 +128,7 @@ describe("skill grants", () => {
|
||||
importSkill(h, "Release Gate", "release-gate");
|
||||
const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" }));
|
||||
expect(denied).toContain("ERROR");
|
||||
expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false);
|
||||
expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(false);
|
||||
});
|
||||
|
||||
test("the humanizer house-style skill reaches every agent without bindings and cannot be revoked", () => {
|
||||
@@ -136,18 +136,18 @@ describe("skill grants", () => {
|
||||
const skills = new SkillService(h.connection.db, "");
|
||||
// A non-forced skill grants nothing on its own - not even to the CEO.
|
||||
importSkill(h, "Release Gate", "release-gate");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(false);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(false);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true, "en")).toBe(false);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(false);
|
||||
// Once the forced skill exists in the catalogue, everyone carries it.
|
||||
importSkill(h, "humanizer", "humanizer");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.ceoId, true, "en")).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(true);
|
||||
// House style is not revocable, and granting it is a no-op with feedback.
|
||||
const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "humanizer" }));
|
||||
expect(revoked).toContain("ERROR");
|
||||
expect(revoked).toContain("house style");
|
||||
const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "Dana", skill: "humanizer" }));
|
||||
expect(granted).toContain("automatically");
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true)).toBe(true);
|
||||
expect(skills.hasRuntimeSkills(h.orgId, h.danaId, true, "en")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe("editing a task", () => {
|
||||
projectId: ids.projectA, goalId: ids.goalA, workspaceId: ids.wsA, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
const edit = (change: { projectId: string | null; goalId: string | null; workspaceId: string | null }) => connection.transaction(() => organization.updateTask({
|
||||
const edit = (change: { projectId: string | null; goalId: string | null; workspaceId: string | null }) => connection.transaction(() => organization.updateTask({ workLanguage: null,
|
||||
orgId: ids.org, taskId: ids.task, title: "Ship", description: "d", assigneeAgentId: null, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL,
|
||||
parentTaskId: null, requiresFilesystem: true, writingMode: WRITING_MODE.TECHNICAL, ...change,
|
||||
}));
|
||||
|
||||
@@ -734,7 +734,7 @@ describe("task execution service", () => {
|
||||
expect(connection.db.prepare("select count(*) as n from audit_events where entity_id = ? and action = 'stalled'").get(taskId)).toEqual({ n: 1 });
|
||||
expect(notified).toBe(1);
|
||||
|
||||
organization.updateTask({ orgId, taskId, title: "Ship it", description: "Narrower scope.", assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(connection.db, taskId) });
|
||||
organization.updateTask({ workLanguage: null, orgId, taskId, title: "Ship it", description: "Narrower scope.", assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(connection.db, taskId) });
|
||||
expect(connection.db.prepare("select stalled_reason from tasks where id = ?").get(taskId)).toEqual({ stalled_reason: null });
|
||||
expect(connection.transaction(() => execution.enqueueHeartbeatRuns())).toHaveLength(1);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("task hierarchy", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
const reparent = (title: string, parent: string | null) => organization.updateTask({
|
||||
const reparent = (title: string, parent: string | null) => organization.updateTask({ workLanguage: null,
|
||||
orgId, taskId: ids[title], title, description: "d", assigneeAgentId: null, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: parent,
|
||||
...currentLinks(connection.db, ids[title]),
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("task status authority", () => {
|
||||
expect(h.as("dev", { status: "backlog", evidence: 123 })).toBe('ERROR: "evidence" is required.');
|
||||
h.as("dev", { status: "done", evidence: "file_id=fil_1" });
|
||||
const reviewId = (h.connection.db.prepare("select id from tasks where review_of_task_id = ?").get(h.ids.task) as { id: string }).id;
|
||||
h.organization.updateTask({ orgId: h.orgId, taskId: h.ids.task, title: "Login page", description: "Build it.", assigneeAgentId: h.ids.cto, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(h.connection.db, h.ids.task) });
|
||||
h.organization.updateTask({ workLanguage: null, orgId: h.orgId, taskId: h.ids.task, title: "Login page", description: "Build it.", assigneeAgentId: h.ids.cto, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null, ...currentLinks(h.connection.db, h.ids.task) });
|
||||
expect(h.status()).toBe("in_progress");
|
||||
expect(h.connection.db.prepare("select status from tasks where id = ?").get(reviewId)).toEqual({ status: "done" });
|
||||
expect(h.as("cto", { status: "done", evidence: "Rebuilt the form." })).toBe(`Task ${h.ids.task} moved to review: done needs sign-off from your manager. Cleo will review it.`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool } from "../../src/main/services/agent-tools";
|
||||
import { taskWorkLanguage } from "../../src/main/services/work-language";
|
||||
import { workLanguageSchema } from "../../src/main/database/migrations/104-work-language";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, WRITING_MODE } from "../../src/shared/domain";
|
||||
|
||||
describe("the working language", () => {
|
||||
test("is the company's unless a task sets its own, and delegated work keeps its parent's", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-work-language-${randomUUID()}.db`));
|
||||
const db = connection.db;
|
||||
const organization = new OrganizationService(db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let taskId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
ceoId = organization.createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" });
|
||||
taskId = organization.createTask({
|
||||
orgId, title: "Plan", description: "d", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: ceoId, risk: RISK_LEVEL.LOW,
|
||||
projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
expect(taskWorkLanguage(db, orgId, taskId)).toBe("en");
|
||||
|
||||
// The display language plays no part.
|
||||
organization.updateUiPreferences({ language: "zh" });
|
||||
expect(taskWorkLanguage(db, orgId, taskId)).toBe("en");
|
||||
|
||||
organization.updateOrganization({
|
||||
orgId, name: "Acme", description: "", mission: "m", goal: "g", executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false, workLanguage: "zh",
|
||||
});
|
||||
expect(taskWorkLanguage(db, orgId, taskId)).toBe("zh");
|
||||
|
||||
const edit = (workLanguage: "en" | "zh" | null) => connection.transaction(() => organization.updateTask({
|
||||
orgId, taskId, title: "Plan", description: "d", assigneeAgentId: ceoId, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL,
|
||||
parentTaskId: null, projectId: null, goalId: null, workspaceId: null, requiresFilesystem: false, writingMode: WRITING_MODE.CONVERSATIONAL, workLanguage,
|
||||
}));
|
||||
edit("en");
|
||||
expect(taskWorkLanguage(db, orgId, taskId)).toBe("en");
|
||||
|
||||
const delegated = dispatchAgentTool({ db, organization, chat: new ChatService(db) }, {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId, sourceThreadId: null, workspace: null,
|
||||
}, { id: "call_1", name: "create_task", arguments: { title: "Draft", description: "d" } });
|
||||
const childId = /task_id=([^)\s]+)\)/.exec(delegated)![1];
|
||||
expect(db.prepare("select work_language from tasks where id = ?").get(childId)).toEqual({ work_language: "en" });
|
||||
|
||||
edit(null);
|
||||
expect(taskWorkLanguage(db, orgId, taskId)).toBe("zh");
|
||||
});
|
||||
|
||||
test("an existing company keeps the language its agents had, taken from the app setting", () => {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`
|
||||
create table schema_migrations(version integer, name text, applied_at text);
|
||||
create table app_state(id integer primary key, language text);
|
||||
create table organizations(id text primary key);
|
||||
create table tasks(id text primary key, org_id text);
|
||||
insert into app_state(id, language) values (1, 'zh');
|
||||
insert into organizations(id) values ('org_a');
|
||||
`);
|
||||
db.exec(workLanguageSchema);
|
||||
expect(db.prepare("select work_language from organizations").get()).toEqual({ work_language: "zh" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user