diff --git a/src/main/app/ipc-contracts/organization.ts b/src/main/app/ipc-contracts/organization.ts index 5fa0b312..db1515de 100644 --- a/src/main/app/ipc-contracts/organization.ts +++ b/src/main/app/ipc-contracts/organization.ts @@ -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, 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, deleteTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType, setTaskBlocked: z.strictObject({ orgId: id, taskId: id, reason: z.string().nullable() }) satisfies z.ZodType, @@ -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, resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType, createProject: z.strictObject({ diff --git a/src/main/database/migrations/104-work-language.ts b/src/main/database/migrations/104-work-language.ts new file mode 100644 index 00000000..98fca363 --- /dev/null +++ b/src/main/database/migrations/104-work-language.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 93155f75..1ca9db47 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 02638b50..43620cd8 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -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); diff --git a/src/main/services/organization-profile.ts b/src/main/services/organization-profile.ts index 4eab1f0e..0929d450 100644 --- a/src/main/services/organization-profile.ts +++ b/src/main/services/organization-profile.ts @@ -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).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]])) } }); } diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 3cd99051..356beee6 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -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, ); diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index e0aaf6e4..98f96dea 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -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, diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index f34dd24c..4908253a 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -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 diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index f250f6f0..0b1207e9 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -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); diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index d2d1505a..8342924b 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -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"); diff --git a/src/main/services/work-language.ts b/src/main/services/work-language.ts new file mode 100644 index 00000000..36aee923 --- /dev/null +++ b/src/main/services/work-language.ts @@ -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; +} diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index b94373c8..1411c10f 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -695,6 +695,11 @@ export const zh: Record = { "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}。", diff --git a/src/renderer/src/pages/NewTaskDialog.tsx b/src/renderer/src/pages/NewTaskDialog.tsx index 25109521..f4c9c303 100644 --- a/src/renderer/src/pages/NewTaskDialog.tsx +++ b/src/renderer/src/pages/NewTaskDialog.tsx @@ -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) => )} + diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index d3d69956..2336d331 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -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) { 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 + + + +

{t("Agents write messages, tasks, documents and deliverables in it. The display language does not change it, and a task can set its own.")}

{t("Changes apply to work from now on; requests already waiting keep their state.")}
diff --git a/src/renderer/src/pages/settings/WorkspacePanel.tsx b/src/renderer/src/pages/settings/WorkspacePanel.tsx index 8e2288bf..450b714b 100644 --- a/src/renderer/src/pages/settings/WorkspacePanel.tsx +++ b/src/renderer/src/pages/settings/WorkspacePanel.tsx @@ -114,6 +114,7 @@ export function WorkspacePanel({ + {t("Changes only what you see; agents keep the company's working language.")} diff --git a/src/renderer/src/pages/work/TaskDetailDialog.tsx b/src/renderer/src/pages/work/TaskDetailDialog.tsx index 5ecf30f3..02e0a7fa 100644 --- a/src/renderer/src/pages/work/TaskDetailDialog.tsx +++ b/src/renderer/src/pages/work/TaskDetailDialog.tsx @@ -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 && ( ) => void; onCancel: () => void; }) { @@ -374,6 +379,7 @@ function EditOverlay({ task, agents, allTasks, projects, goals, workspaces, erro {WRITING_OPTIONS.map((option) => )} + diff --git a/src/renderer/src/pages/work/WorkLanguageField.tsx b/src/renderer/src/pages/work/WorkLanguageField.tsx new file mode 100644 index 00000000..7d303725 --- /dev/null +++ b/src/renderer/src/pages/work/WorkLanguageField.tsx @@ -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 ( + + + + ); +} diff --git a/src/renderer/src/pages/work/task-helpers.ts b/src/renderer/src/pages/work/task-helpers.ts index 1441d774..45a7518c 100644 --- a/src/renderer/src/pages/work/task-helpers.ts +++ b/src/renderer/src/pages/work/task-helpers.ts @@ -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 = { 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)") }, diff --git a/src/shared/commands/organization.ts b/src/shared/commands/organization.ts index 98a0b41a..51f9d9c5 100644 --- a/src/shared/commands/organization.ts +++ b/src/shared/commands/organization.ts @@ -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 { diff --git a/src/shared/commands/skills.ts b/src/shared/commands/skills.ts index 53f1a54a..ab5ab989 100644 --- a/src/shared/commands/skills.ts +++ b/src/shared/commands/skills.ts @@ -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 { diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 007dd73f..97a38598 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -33,6 +33,9 @@ export type ProviderAuthType = ValueOf; export type SocialPlatform = ValueOf; export type FileScanState = ValueOf; +/** 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. */ diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index 05eff119..6633d4dd 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -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(); diff --git a/tests/integration/collaboration-flow.test.ts b/tests/integration/collaboration-flow.test.ts index caa4d271..ca5c5365 100644 --- a/tests/integration/collaboration-flow.test.ts +++ b/tests/integration/collaboration-flow.test.ts @@ -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(); diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 92962318..79ea333b 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -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 () => { diff --git a/tests/unit/builtin-skills.test.ts b/tests/unit/builtin-skills.test.ts index 75ed9a20..417daae5 100644 --- a/tests/unit/builtin-skills.test.ts +++ b/tests/unit/builtin-skills.test.ts @@ -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"); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 5b867d69..549352ae 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -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 }); }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index 51cf95eb..ca1026fa 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -315,7 +315,7 @@ describe("execution modes", () => { })); const task = () => db.prepare("select * from tasks where id = ?").get(taskId) as unknown as Parameters[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), })); diff --git a/tests/unit/organization-update.test.ts b/tests/unit/organization-update.test.ts index 1b27da23..b17f1f59 100644 --- a/tests/unit/organization-update.test.ts +++ b/tests/unit/organization-update.test.ts @@ -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(); }); diff --git a/tests/unit/skill-compatibility.test.ts b/tests/unit/skill-compatibility.test.ts index 0ce8b89c..a32f4890 100644 --- a/tests/unit/skill-compatibility.test.ts +++ b/tests/unit/skill-compatibility.test.ts @@ -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 }; }; diff --git a/tests/unit/skill-grants.test.ts b/tests/unit/skill-grants.test.ts index 2760e1b4..00b8b388 100644 --- a/tests/unit/skill-grants.test.ts +++ b/tests/unit/skill-grants.test.ts @@ -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); }); }); diff --git a/tests/unit/task-edit-links.test.ts b/tests/unit/task-edit-links.test.ts index 46b8da36..e3870a65 100644 --- a/tests/unit/task-edit-links.test.ts +++ b/tests/unit/task-edit-links.test.ts @@ -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, })); diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index 9c4d7d2a..ad5b44bb 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -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); diff --git a/tests/unit/task-hierarchy.test.ts b/tests/unit/task-hierarchy.test.ts index f6ed0b84..d039e23e 100644 --- a/tests/unit/task-hierarchy.test.ts +++ b/tests/unit/task-hierarchy.test.ts @@ -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]), }); diff --git a/tests/unit/task-status-authority.test.ts b/tests/unit/task-status-authority.test.ts index 1d8607df..b304b4df 100644 --- a/tests/unit/task-status-authority.test.ts +++ b/tests/unit/task-status-authority.test.ts @@ -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.`); diff --git a/tests/unit/work-language.test.ts b/tests/unit/work-language.test.ts new file mode 100644 index 00000000..8e5edf87 --- /dev/null +++ b/tests/unit/work-language.test.ts @@ -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" }); + }); +});