Compare commits

..

6 Commits

Author SHA1 Message Date
Molecule AI Core Platform Lead a5eabae637 trigger: re-run sop-tier-check post-#231 merge (orchestrator drain)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-10 04:40:32 +00:00
Molecule AI Core Platform Lead 0345d9872c trigger: re-run sop-tier-check after #229 fix
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 5s
2026-05-10 04:32:51 +00:00
core-fe 71174544ef Revert "Re-export extractMessageText for ConversationTraceModal tests"
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
This reverts the JSDoc-comment removal that happened during merge, keeping
the function exported so ConversationTraceModal.test.ts can import it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 03:29:46 +00:00
Molecule AI Core Platform Lead d6c30c9615 Merge remote-tracking branch 'origin/main' into trig-227
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 5s
2026-05-10 02:58:38 +00:00
Molecule AI Core Platform Lead 2f9996a88d trigger
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 5s
2026-05-10 02:58:22 +00:00
core-fe d35403d402 test(canvas): add tests for extractMessageText and providerIdForModel
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
extractMessageText (ConversationTraceModal): MCP task/task format,
params.message.parts, result.parts/root.text, plain string result,
priority order, error resilience.

providerIdForModel (MissingKeysModal): model match, no match,
whitespace trimming, undefined models, no required_env, multi-env sort.

Also exports extractMessageText from ConversationTraceModal for testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 02:54:54 +00:00
7 changed files with 235 additions and 309 deletions
+8 -40
View File
@@ -208,50 +208,20 @@ fi
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
# 6. For each approver: skip self-review; probe team membership by id.
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
# Pre/post spaces ensure case patterns *${_t}* match even when the name
# is the first or last entry (bash case *word* needs delimiters on both sides).
#
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
# member — a superset of team membership. Accepting it as a fallback means
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
# This is safe because: (a) org membership is a prerequisite for every
# eligible team; (b) the AND-composition of internal#189 still requires
# multiple independent approvers; (c) any token with read:repository can
# see the approving reviews, so bypass requires a colluding approver.
# Build $APPROVER_TEAMS[<user>]=space-separated-team-names.
declare -A APPROVER_TEAMS
for U in $APPROVERS; do
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
_any_team_success="no"
for T in "${!TEAM_ID[@]}"; do
ID="${TEAM_ID[$T]}"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/teams/${ID}/members/${U}")
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:-}${APPROVER_TEAMS[$U]:+ }$T"
debug "$U qualifies for team $T"
_any_team_success="yes"
fi
done
# Fallback: if every team probe returned 403, try org membership.
# "??" teams were never resolved to IDs so they never entered the loop.
# If the user is an org member, credit them as being in each queried team
# (engineers, managers, ceo are all org-level). This is safe because org
# membership is a prerequisite for all three, and bypass requires a colluding
# approver (same risk as before the AND-composition).
if [ "$_any_team_success" = "no" ]; then
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/members/${U}")
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
if [ "$ORG_CODE" = "204" ]; then
for T in "${!TEAM_ID[@]}"; do
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
done
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
fi
fi
done
# 7. Evaluate the tier expression.
@@ -259,11 +229,11 @@ done
# legacy OR-gate: use the simplified loop from before internal#189.
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
OK=""
for _u in "${!APPROVER_TEAMS[@]}"; do
for _t2 in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$_u]}" in
*${_t2}*)
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
for U in "${!APPROVER_TEAMS[@]}"; do
for T in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$U]}" in
*"$T"*)
echo "::notice::approver $U is in team $T (eligible for $TIER)"
OK="yes"
break
;;
@@ -295,10 +265,8 @@ for _raw_clause in $EXPR; do
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
for _u in "${!APPROVER_TEAMS[@]}"; do
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
# Pattern *${_t}* matches team name anywhere in the space-padded string.
case "${APPROVER_TEAMS[$_u]}" in
*${_t}*)
*"$_t"*)
_clause_passed="yes"
debug "clause \"$_t\": satisfied by $_u"
break
@@ -13,7 +13,8 @@ interface Props {
onClose: () => void;
}
function extractMessageText(body: Record<string, unknown> | null): string {
/** Exported for unit testing — see ConversationTraceModal.test.ts */
export function extractMessageText(body: Record<string, unknown> | null): string {
if (!body) return "";
try {
// Simple task format from MCP server: {task: "..."}
@@ -0,0 +1,156 @@
// @vitest-environment jsdom
/**
* Tests for ConversationTraceModal's extractMessageText helper.
*
* Covers: MCP simple task format, request params.message.parts extraction,
* response result.parts extraction, result.root.text extraction, plain string
* result, null input, malformed input, empty strings.
*/
import { describe, expect, it } from "vitest";
import { extractMessageText } from "../ConversationTraceModal";
describe("extractMessageText — MCP simple task format", () => {
it("extracts text from body.task field", () => {
const body = { task: "Deploy the agent to production" };
expect(extractMessageText(body)).toBe("Deploy the agent to production");
});
it("returns empty string when body is null", () => {
expect(extractMessageText(null)).toBe("");
});
it("returns empty string when body is undefined", () => {
expect(extractMessageText(undefined as unknown as null)).toBe("");
});
});
describe("extractMessageText — request params.message format", () => {
it("extracts text from params.message.parts[].text", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello world" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello world");
});
it("joins multiple parts with newlines", () => {
const body = {
params: {
message: {
parts: [
{ text: "First part" },
{ text: "Second part" },
{ text: "Third part" },
],
},
},
};
expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part");
});
it("ignores parts without text field", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello\nWorld");
});
it("returns empty string when params.message is absent", () => {
const body = { params: {} };
expect(extractMessageText(body)).toBe("");
});
});
describe("extractMessageText — response result format", () => {
it("extracts text from result.parts[].text", () => {
const body = {
result: {
parts: [{ text: "Agent response" }],
},
};
expect(extractMessageText(body)).toBe("Agent response");
});
it("extracts text from result.parts[].root.text", () => {
const body = {
result: {
parts: [{ root: { text: "Root response text" } }],
},
};
expect(extractMessageText(body)).toBe("Root response text");
});
it("prefers parts[].text over parts[].root.text", () => {
const body = {
result: {
parts: [
{ text: "Direct text" },
{ root: { text: "Root text" } },
],
},
};
// Both are non-empty strings, so the first one wins (filter picks the first)
// The implementation: rText from rParts[0].text = "Direct text"
expect(extractMessageText(body)).toBe("Direct text");
});
});
describe("extractMessageText — plain string result", () => {
it("returns body.result when it is a plain string", () => {
const body = { result: "Simple string response" };
expect(extractMessageText(body)).toBe("Simple string response");
});
});
describe("extractMessageText — priority order", () => {
it("prefers task format over params format", () => {
const body = {
task: "Task text",
params: { message: { parts: [{ text: "Params text" }] } },
};
// Implementation: checks task first, returns if non-empty
expect(extractMessageText(body)).toBe("Task text");
});
it("prefers params format over result format", () => {
const body = {
params: { message: { parts: [{ text: "Params text" }] } },
result: { parts: [{ text: "Result text" }] },
};
// Implementation: checks params.message.parts first (after task)
expect(extractMessageText(body)).toBe("Params text");
});
});
describe("extractMessageText — error resilience", () => {
it("returns empty string on malformed input", () => {
expect(extractMessageText({})).toBe("");
expect(extractMessageText({ params: null })).toBe("");
expect(extractMessageText({ result: null })).toBe("");
});
it("returns empty string when all fields are absent", () => {
expect(extractMessageText({ random: "field" })).toBe("");
});
it("handles missing parts array gracefully", () => {
const body = { params: { message: {} } };
expect(extractMessageText(body)).toBe("");
});
it("handles parts with undefined text gracefully", () => {
const body = {
result: {
parts: [{ text: undefined }, { text: "valid" }],
},
};
expect(extractMessageText(body)).toBe("valid");
});
});
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
/**
* Tests for MissingKeysModal's providerIdForModel helper.
*
* Covers: model match, no match, empty modelId, whitespace-only modelId,
* model with no required_env, models undefined, single vs multiple env vars,
* stable sort order for env var ordering.
*/
import { describe, expect, it } from "vitest";
import { providerIdForModel } from "../MissingKeysModal";
describe("providerIdForModel — match behavior", () => {
it("returns sorted-joined env vars when model is found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY");
});
it("returns null when model is not found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("unknown-model", models)).toBeNull();
});
it("returns null when models is undefined", () => {
expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull();
});
it("returns null when modelId is empty string", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel("", models)).toBeNull();
});
it("returns null when modelId is whitespace-only", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" ", models)).toBeNull();
});
it("trims whitespace from modelId before matching", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" claude ", models)).toBe("KEY");
});
});
describe("providerIdForModel — required_env variations", () => {
it("returns null when model has no required_env", () => {
const models = [{ id: "local-model", name: "Local Model", required_env: [] }];
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("returns null when model.required_env is undefined", () => {
const models = [{ id: "local-model", name: "Local Model" }] as Array<{
id: string;
name: string;
required_env?: string[];
}>;
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("sorts and joins multiple required_env alphabetically", () => {
const models = [
{ id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] },
];
// Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY
expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY");
});
});
@@ -1,75 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for createMessage — the ChatMessage factory from types.ts.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMessage } from "../tabs/chat/types";
describe("createMessage", () => {
beforeEach(() => {
// Freeze time so timestamp is deterministic.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
// Stub crypto.randomUUID so message IDs are deterministic.
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("creates a message with the correct role", () => {
const userMsg = createMessage("user", "hello");
expect(userMsg.role).toBe("user");
const agentMsg = createMessage("agent", "hi there");
expect(agentMsg.role).toBe("agent");
const systemMsg = createMessage("system", "prompt loaded");
expect(systemMsg.role).toBe("system");
});
it("creates a message with the correct content", () => {
const msg = createMessage("user", "Deploy the agent now");
expect(msg.content).toBe("Deploy the agent now");
});
it("sets a deterministic id via crypto.randomUUID", () => {
const msg = createMessage("agent", "response");
expect(msg.id).toBe("fixed-uuid-1234");
});
it("sets a deterministic ISO timestamp", () => {
const msg = createMessage("user", "hello");
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
});
it("omits attachments field when none provided", () => {
const msg = createMessage("user", "hello");
expect(msg.attachments).toBeUndefined();
});
it("omits attachments field when empty array is provided", () => {
const msg = createMessage("agent", "result", []);
expect(msg.attachments).toBeUndefined();
});
it("includes attachments field when non-empty array is provided", () => {
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
const msg = createMessage("agent", "see attached", atts);
expect(msg.attachments).toEqual(atts);
});
it("returns a frozen object (prevents accidental mutation)", () => {
const msg = createMessage("user", "hello");
expect(Object.isFrozen(msg)).toBe(true);
});
it("returns a plain object with expected keys", () => {
const msg = createMessage("user", "hello");
expect(Object.keys(msg).sort()).toEqual(
["id", "role", "content", "timestamp"].sort()
);
});
});
@@ -1,104 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
*/
import { describe, it, expect } from "vitest";
import { getIcon } from "../tabs/FilesTab/tree";
describe("getIcon", () => {
// ─── Directories ──────────────────────────────────────────────────────────
it("returns 📁 for directories regardless of extension", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("node_modules", true)).toBe("📁");
expect(getIcon(".claude", true)).toBe("📁");
expect(getIcon("foo/bar/baz", true)).toBe("📁");
});
it("returns 📁 even for paths that look like files", () => {
expect(getIcon("foo.txt", true)).toBe("📁");
expect(getIcon("script.sh", true)).toBe("📁");
});
// ─── Files by extension ────────────────────────────────────────────────────
it("returns 📄 for .md files", () => {
expect(getIcon("README.md", false)).toBe("📄");
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
expect(getIcon("docs/guide.md", false)).toBe("📄");
});
it("returns ⚙ for .yaml and .yml files", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
expect(getIcon("values.yml", false)).toBe("⚙");
expect(getIcon("deploy.yaml", false)).toBe("⚙");
});
it("returns 🐍 for .py files", () => {
expect(getIcon("main.py", false)).toBe("🐍");
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
});
it("returns 💠 for .ts and .tsx files", () => {
expect(getIcon("index.ts", false)).toBe("💠");
expect(getIcon("Component.tsx", false)).toBe("💠");
expect(getIcon("types.d.ts", false)).toBe("💠");
});
it("returns 📜 for .js files", () => {
expect(getIcon("bundle.js", false)).toBe("📜");
expect(getIcon("src/index.js", false)).toBe("📜");
});
it("returns {} for .json files", () => {
expect(getIcon("package.json", false)).toBe("{}");
expect(getIcon("config.json", false)).toBe("{}");
});
it("returns 🌐 for .html files", () => {
expect(getIcon("index.html", false)).toBe("🌐");
expect(getIcon("templates/page.html", false)).toBe("🌐");
});
it("returns 🎨 for .css files", () => {
expect(getIcon("style.css", false)).toBe("🎨");
expect(getIcon("src/app.css", false)).toBe("🎨");
});
it("returns ▸ for .sh files", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
});
// ─── Fallback ─────────────────────────────────────────────────────────────
it("returns 📄 for unknown extensions", () => {
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("notes.txt", false)).toBe("📄");
expect(getIcon("archive.tar.gz", false)).toBe("📄");
});
it("returns 📄 for paths with no extension", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
});
// ─── Case sensitivity ──────────────────────────────────────────────────────
it("is case-insensitive for extension lookup", () => {
expect(getIcon("image.PNG", false)).toBe("📄");
expect(getIcon("data.JSON", false)).toBe("{}");
expect(getIcon("script.SH", false)).toBe("▸");
});
// ─── Nested paths ─────────────────────────────────────────────────────────
it("uses the leaf extension for nested paths", () => {
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
});
});
@@ -1,89 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
*/
import { describe, expect, it } from "vitest";
import {
getRuntimeProfile,
provisionTimeoutForRuntime,
DEFAULT_RUNTIME_PROFILE,
RUNTIME_PROFILES,
} from "../runtimeProfiles";
describe("getRuntimeProfile", () => {
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
const result = getRuntimeProfile(undefined);
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
const result = getRuntimeProfile("");
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
const result = getRuntimeProfile("unknown-lang");
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
expect(RUNTIME_PROFILES).toEqual({});
const result = getRuntimeProfile("claude-code");
expect(result.provisionTimeoutMs).toBe(120_000);
});
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
expect(result.provisionTimeoutMs).toBe(300_000);
});
it("overrides wins over RUNTIME_PROFILES entry", () => {
// Even if RUNTIME_PROFILES had an entry, overrides take priority
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
expect(result.provisionTimeoutMs).toBe(999_000);
});
it("uses overrides even when runtime is undefined", () => {
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
expect(result.provisionTimeoutMs).toBe(60_000);
});
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
// The return type is guaranteed non-nullable
const result = getRuntimeProfile(undefined);
expect(typeof result.provisionTimeoutMs).toBe("number");
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
});
});
describe("provisionTimeoutForRuntime", () => {
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
expect(provisionTimeoutForRuntime("")).toBe(120_000);
});
it("returns overrides value when overrides provided", () => {
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
});
it("returns 120_000 for any unknown runtime", () => {
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
});
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
[undefined, undefined],
["claude-code", undefined],
["langgraph", { provisionTimeoutMs: 500_000 }],
[undefined, { provisionTimeoutMs: 45_000 }],
];
for (const [runtime, overrides] of cases) {
const profile = getRuntimeProfile(runtime, overrides);
const direct = provisionTimeoutForRuntime(runtime, overrides);
expect(direct).toBe(profile.provisionTimeoutMs);
}
});
});