Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@

### Fixed

- #314:招聘/编辑能力面板在绑定任一 MCP 连接器时给出明确警告——当前全部内置引擎都会在第一回合以 `qoder.mcp_binding_unsupported` 拒绝员工级 MCP,绑定仍可保存,但不再静默让操作员以为该能力已经可用。
- #243:Agent Host 登记顺序改为从详尽的 `HOST_DEFINITIONS` 派生,新增引擎时不能再静默漏掉。

- #325: The employee card now keeps its per-task and per-day budget declarations visible after turns instead of replacing the declaration with latest-turn usage. Usage remains available in the reports surfaces, where over-budget percentages stay truthful while the visual meter fill is bounded to its track.

- #297:创建数字员工默认先展示 Agent 与基础信息;Agent 草案、头像、权限、能力、记忆、运行模式和预算改为按需展开,保留安全默认值、原有字段校验与 hire 请求 payload。
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/renderer/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3422,6 +3422,7 @@
.owb-hire-mcp__tools { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 4px; }
.owb-hire-mcp__tools button { border: 1px solid var(--ui-border); border-radius: 999px; background: transparent; color: var(--ui-foreground-muted); padding: 3px 7px; font: 9px var(--ui-font-mono, monospace); cursor: pointer; }
.owb-hire-mcp__tools button.is-selected { border-color: color-mix(in srgb, var(--ui-success) 58%, var(--ui-border)); background: color-mix(in srgb, var(--ui-success) 15%, transparent); color: var(--ui-success); }
.owb-hire-mcp-unsupported { margin: 0; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--ui-warning, #d97706) 45%, var(--ui-border)); border-radius: 8px; background: color-mix(in srgb, var(--ui-warning, #d97706) 12%, var(--ui-surface)); color: var(--ui-foreground); font-size: 11px; line-height: 1.45; }
.owb-hire-capability-note { margin: 0; color: var(--ui-foreground-muted); font-size: 10px; line-height: 1.4; }
.owb-tag--mcp { border-color: color-mix(in srgb, var(--ui-info, var(--ui-primary)) 46%, var(--ui-border)); color: var(--ui-info, var(--ui-primary)); }

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/renderer/src/org/PermissionsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ export function CapabilityPicker({
</div>
</section>
</div>
{(permissions.mcpServers ?? []).length > 0 ? (
<p className="owb-hire-mcp-unsupported" role="status">
{t("hire.mcpUnsupportedHint")}
</p>
) : null}
<p className="owb-hire-capability-note">{t("hire.capabilityPermissionHint")}</p>
</section>
);
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/renderer/test/capability-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState } from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { HirePermissions } from "@roleweave/shared";
import { CapabilityPicker } from "../src/org/PermissionsEditor";

const EMPTY: HirePermissions = {
tools: ["Read"],
rules: [{ scope: "position", resource: "./knowledge/**", actions: ["read"] }],
skills: [],
mcpServers: [],
};

function Harness({ initial = EMPTY }: { initial?: HirePermissions }) {
const [permissions, setPermissions] = useState(initial);
return <CapabilityPicker permissions={permissions} onChange={setPermissions} />;
}

describe("CapabilityPicker MCP hire warning (#314)", () => {
it("does not warn until an MCP connector is bound", () => {
render(<Harness />);
expect(screen.queryByRole("status")).toBeNull();
});

it("warns that bundled engines reject employee MCP as soon as a server is bound", () => {
render(<Harness />);
fireEvent.click(screen.getByRole("button", { name: /工作区网盘/ }));
expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported");
expect(screen.getByRole("status")).toHaveTextContent("第一回合");
});

it("also warns when editing an employee that already has a binding", () => {
render(
<Harness
initial={{
...EMPTY,
mcpServers: [{ id: "issue-tracker", tools: ["search"] }],
}}
/>,
);
expect(screen.getByRole("status")).toHaveTextContent("qoder.mcp_binding_unsupported");
});
});
8 changes: 5 additions & 3 deletions apps/server/src/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ interface HostDefinition {
readonly capabilities: readonly string[];
}

const HOST_ORDER = ["qoder", "claude-code", "claude-local", "codex", "codex-local", "workbuddy"] as const satisfies readonly AgentHostId[];

/**
* Capabilities describe the control-plane contract, not provider account
* state. No executable path, token, or raw CLI output is ever part of this
Expand Down Expand Up @@ -124,6 +122,10 @@ const HOST_DEFINITIONS: Readonly<Record<AgentHostId, HostDefinition>> = {
},
};

/** Derived from HOST_DEFINITIONS so adding an AgentHostId is a compile error
* until the catalog entry exists; a handwritten subset would stay legal. */
const HOST_ORDER = Object.keys(HOST_DEFINITIONS) as AgentHostId[];

const LOCAL_PROBE_FAILURES = new Set<AgentHostLocalProbeStatus>([
"timed_out",
"unsupported_version",
Expand All @@ -135,7 +137,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

function isKnownHostId(value: unknown): value is AgentHostId {
return typeof value === "string" && (HOST_ORDER as readonly string[]).includes(value);
return typeof value === "string" && Object.hasOwn(HOST_DEFINITIONS, value);
}

function isBoolean(value: unknown): value is boolean {
Expand Down
15 changes: 15 additions & 0 deletions apps/server/test/agent-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ function health(overrides: Record<string, unknown> = {}) {
};
}

test("inherited Object keys are not treated as Agent Host ids", () => {
for (const inherited of ["constructor", "toString", "hasOwnProperty"]) {
assert.deepEqual(getAgentHostCapabilities({ id: inherited, engine: inherited }), []);
assert.equal(agentHostSupportsEmployeeMcp(inherited), false);
}
});

test("Host order is derived from the exhaustive catalog, not a handwritten subset", () => {
const hosts = listRegisteredAgentHosts(health());
assert.deepEqual(
hosts.map((host) => host.id).sort(),
["claude-code", "claude-local", "codex", "codex-local", "qoder", "workbuddy"],
);
});

test("registers every contracted Host from a health snapshot", () => {
const hosts = listRegisteredAgentHosts(health());

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ export const enCatalog: Record<string, string> = {
"hire.mcpBound": "Bound",
"hire.mcpAvailable": "Available",
"hire.mcpToolsAria": "MCP tool permissions",
"hire.mcpUnsupportedHint": "Every bundled engine currently rejects employee-level MCP bindings on the first turn (qoder.mcp_binding_unsupported). The grant can be saved, but the employee's first task will fail immediately until an engine supports MCP.",
"hire.capabilityPermissionHint": "Capability binding and resource access are separate; MCP tools can be turned off individually.",
"hire.actionRead": "Read",
"hire.actionCreate": "Create",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ export const zhCatalog: Record<string, string> = {
"hire.mcpBound": "已绑定",
"hire.mcpAvailable": "可选",
"hire.mcpToolsAria": "MCP 工具权限",
"hire.mcpUnsupportedHint": "当前全部内置引擎都会在第一回合拒绝员工级 MCP 绑定(qoder.mcp_binding_unsupported)。绑定可以保存,但该员工的首次任务会立刻失败,直到引擎支持 MCP。",
"hire.capabilityPermissionHint": "能力绑定和资源权限分开控制;MCP 工具可以逐个关闭。",
"hire.actionRead": "查",
"hire.actionCreate": "新建",
Expand Down