mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing
This commit is contained in:
@@ -681,3 +681,114 @@ test("parseSystemPromptSections splits on the LAST occurrence of the canonical d
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Modern [Team Instructions] bracket-header extraction ─────────────────────
|
||||
|
||||
test("parseSystemPromptSections (modern) extracts bracket Team Instructions after Base+System", () => {
|
||||
// with_team() emits "\n\n[Team Instructions]\n{instructions}" as a top-level
|
||||
// bracket section — the same framing used by with_core() and with_canvas().
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"You are a helpful assistant.",
|
||||
"",
|
||||
"[System]",
|
||||
"You are Agent X.",
|
||||
"",
|
||||
"[Team Instructions]",
|
||||
"Always respond in markdown.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "You are a helpful assistant." },
|
||||
{ title: "System", body: "You are Agent X." },
|
||||
{ title: "Team Instructions", body: "Always respond in markdown." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections (modern) extracts bracket Team Instructions after System-only", () => {
|
||||
// [Base] absent; [Team Instructions] is a direct top-level bracket section.
|
||||
const framed = [
|
||||
"[System]",
|
||||
"You are Agent Y.",
|
||||
"",
|
||||
"[Team Instructions]",
|
||||
"Keep responses concise.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "System", body: "You are Agent Y." },
|
||||
{ title: "Team Instructions", body: "Keep responses concise." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections (modern) handles bracket Team Instructions as start-of-string", () => {
|
||||
// with_team() also handles prompt=None → "[Team Instructions]\n{instructions}".
|
||||
const framed = ["[Team Instructions]", "Instructions only."].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Team Instructions", body: "Instructions only." },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections (modern) pins full 5-section shape: Base+System+Team+Core+Canvas", () => {
|
||||
// Production shape from with_team() + with_core() + with_canvas(): all five sections present.
|
||||
const framed = [
|
||||
"[Base]",
|
||||
"You are a helpful AI assistant running in Buzz.",
|
||||
"",
|
||||
"[System]",
|
||||
"You are Observer Agent. You coordinate multi-agent workflows.",
|
||||
"",
|
||||
"[Team Instructions]",
|
||||
"Always tag on handoff.",
|
||||
"Never expand scope without approval.",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
"I am Observer Agent.",
|
||||
"## Lessons Learned",
|
||||
"Always tag on handoff.",
|
||||
"",
|
||||
"[Channel Canvas]",
|
||||
"Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||||
"Last modified: 2026-07-11T10:00:00Z",
|
||||
"Fetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "You are a helpful AI assistant running in Buzz." },
|
||||
{
|
||||
title: "System",
|
||||
body: "You are Observer Agent. You coordinate multi-agent workflows.",
|
||||
},
|
||||
{
|
||||
title: "Team Instructions",
|
||||
body: "Always tag on handoff.\nNever expand scope without approval.",
|
||||
},
|
||||
{
|
||||
title: "Core Memory",
|
||||
body: "I am Observer Agent.\n## Lessons Learned\nAlways tag on handoff.",
|
||||
},
|
||||
{
|
||||
title: "Channel Canvas",
|
||||
body: "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\nLast modified: 2026-07-11T10:00:00Z\nFetch current content with: buzz canvas get --channel 94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections (modern) does NOT split on bracket [Team Instructions] preceded by only a single newline", () => {
|
||||
// The inline marker is "\n\n[Team Instructions]\n" — a single preceding newline
|
||||
// must be kept literal inside System, same guard as canvas/core.
|
||||
const framed = [
|
||||
"[System]",
|
||||
"Persona preamble.",
|
||||
"[Team Instructions]",
|
||||
"This is persona text, not a real team block.",
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "System",
|
||||
body: "Persona preamble.\n[Team Instructions]\nThis is persona text, not a real team block.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -61,15 +61,12 @@ export function parsePromptText(text: string): {
|
||||
* deterministically.
|
||||
*
|
||||
* The harness composes the value in order:
|
||||
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
|
||||
* with any section omitted when absent. For team-pack agents the persona body
|
||||
* already contains the pack-level instructions appended by `compose_prompt()`
|
||||
* in `buzz-persona/src/resolve.rs`:
|
||||
* `{persona_body}\n\n---\n# Team Instructions\n{pack_instructions}`
|
||||
* Extraction runs in reverse producer order so that each `lastIndexOf` search
|
||||
* operates on the full input and each extraction boundary is unambiguous.
|
||||
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
|
||||
* with any section omitted when absent. Extraction runs in reverse producer
|
||||
* order so that each `lastIndexOf` search operates on the full input and each
|
||||
* extraction boundary is unambiguous.
|
||||
*
|
||||
* Four extraction passes before Base/System parsing:
|
||||
* Five extraction passes:
|
||||
*
|
||||
* 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`.
|
||||
* - Start-of-string: canvas-only input.
|
||||
@@ -80,18 +77,23 @@ export function parsePromptText(text: string): {
|
||||
* 2. **Core** (`[Agent Memory — core]`): appended before canvas by `with_core()`.
|
||||
* Same two cases, same last-occurrence guard.
|
||||
*
|
||||
* 3. **Base/System**: remainder after canvas and core extraction.
|
||||
* 3. **Team Instructions** (`[Team Instructions]`): appended before core by
|
||||
* `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string
|
||||
* or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output
|
||||
* position: after System, before Core Memory.
|
||||
*
|
||||
* 4. **Base/System**: remainder after the three top-level section extractions.
|
||||
* Split on the first `\n[System]\n` boundary; no embedded `[...]` line
|
||||
* inside a body can start a new section.
|
||||
*
|
||||
* 4. **Team Instructions**: if the `System` body contains the exact canonical
|
||||
* delimiter `\n\n---\n# Team Instructions\n` (produced by `compose_prompt()`),
|
||||
* the body is split at the **last** occurrence of that boundary (same
|
||||
* last-occurrence guard as canvas and core). The text before becomes the
|
||||
* `System` body; the text after becomes a `Team Instructions` section
|
||||
* inserted immediately after `System`. Non-canonical lookalikes (bare `---`
|
||||
* without the heading, a `# Team Instructions` on a different line, or only
|
||||
* a single preceding newline) are kept literal inside `System`.
|
||||
* 5. **Legacy Team Instructions** (backward compat): if the `System` body
|
||||
* contains the exact canonical delimiter `\n\n---\n# Team Instructions\n`
|
||||
* (produced by the now-removed `compose_prompt()` in buzz-persona), the body
|
||||
* is split at the **last** occurrence of that boundary. The text before
|
||||
* becomes the `System` body; the text after becomes a `Team Instructions`
|
||||
* section inserted immediately after `System`. Non-canonical lookalikes
|
||||
* (bare `---` without the heading, a `# Team Instructions` on a different
|
||||
* line, or only a single preceding newline) are kept literal inside `System`.
|
||||
*/
|
||||
export function parseSystemPromptSections(
|
||||
systemPrompt: string,
|
||||
@@ -133,7 +135,29 @@ export function parseSystemPromptSections(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Parse Base/System from the remaining prefix ────────────────────────
|
||||
// ── 3. Extract [Team Instructions] (modern runtime framing) ─────────────
|
||||
// with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}"
|
||||
// after [System] and before core/canvas. Same two cases as canvas/core:
|
||||
// start-of-string (team-only input) or the inline double-newline marker
|
||||
// (last occurrence guards against embedded lookalikes preceded by a single \n).
|
||||
const TEAM_HEADER = "[Team Instructions]";
|
||||
const TEAM_MARKER_INLINE = `\n\n${TEAM_HEADER}\n`;
|
||||
let modernTeamBody: string | null = null;
|
||||
|
||||
if (remainder.startsWith(`${TEAM_HEADER}\n`)) {
|
||||
modernTeamBody = remainder.slice(`${TEAM_HEADER}\n`.length).trim();
|
||||
remainder = "";
|
||||
} else {
|
||||
const lastTeam = remainder.lastIndexOf(TEAM_MARKER_INLINE);
|
||||
if (lastTeam !== -1) {
|
||||
modernTeamBody = remainder
|
||||
.slice(lastTeam + TEAM_MARKER_INLINE.length)
|
||||
.trim();
|
||||
remainder = remainder.slice(0, lastTeam);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Parse Base/System from the remaining prefix ────────────────────────
|
||||
// The canonical team-instructions delimiter produced by compose_prompt() in
|
||||
// buzz-persona/src/resolve.rs:
|
||||
// format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}")
|
||||
@@ -181,7 +205,9 @@ export function parseSystemPromptSections(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Append core and canvas sections in producer order ──────────────────
|
||||
// ── 5. Append team (modern), core, and canvas sections in producer order ──
|
||||
if (modernTeamBody)
|
||||
sections.push({ title: "Team Instructions", body: modernTeamBody });
|
||||
if (coreBody) sections.push({ title: "Core Memory", body: coreBody });
|
||||
if (canvasBody) sections.push({ title: "Channel Canvas", body: canvasBody });
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ export function RuntimeErrorTooltip({
|
||||
side="bottom"
|
||||
sideOffset={12}
|
||||
>
|
||||
<span className="leading-4">{detail}</span>
|
||||
<span className="block max-h-48 overflow-y-auto overflow-x-hidden break-words whitespace-pre-line leading-4">
|
||||
{detail}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -463,19 +463,56 @@ function RuntimeAuthError({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
|
||||
}
|
||||
|
||||
function RuntimeCard({
|
||||
installError,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
installResults,
|
||||
onInstallResultsChange,
|
||||
runtime,
|
||||
}: {
|
||||
installError: string | null;
|
||||
isInstalling: boolean;
|
||||
onInstall: () => void;
|
||||
installResults: InstallResultsState;
|
||||
onInstallResultsChange: React.Dispatch<
|
||||
React.SetStateAction<InstallResultsState>
|
||||
>;
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
// Each card owns its own mutation instance so concurrent installs on
|
||||
// different cards each track their own isPending state and callbacks
|
||||
// independently (react-query v5 per-mutate callbacks only fire for the
|
||||
// latest mutate() call on a shared instance, silently dropping earlier ones).
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
const installError = installResults[runtime.id]?.error ?? null;
|
||||
const isInstalling = installMutation.isPending;
|
||||
const isAvailable = runtime.availability === "available";
|
||||
const isReady = runtimeIsReadyForOnboarding(runtime);
|
||||
|
||||
function handleInstall() {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtime.id]: { error: null, success: false },
|
||||
}));
|
||||
|
||||
installMutation.mutate(runtime.id, {
|
||||
onSuccess: (result) => {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtime.id]: result.success
|
||||
? { error: null, success: true }
|
||||
: {
|
||||
error: getInstallErrorMessage(result.steps),
|
||||
success: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtime.id]: {
|
||||
error: error instanceof Error ? error.message : "Install failed.",
|
||||
success: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
@@ -499,7 +536,7 @@ function RuntimeCard({
|
||||
<RuntimeStatus
|
||||
installError={installError}
|
||||
isInstalling={isInstalling}
|
||||
onInstall={onInstall}
|
||||
onInstall={handleInstall}
|
||||
runtime={runtime}
|
||||
/>
|
||||
{!isAvailable && runtimeDetailText(runtime) ? (
|
||||
@@ -517,7 +554,7 @@ function RuntimeCard({
|
||||
{installError ? (
|
||||
<RuntimeErrorTooltip
|
||||
className="absolute inset-x-3 bottom-2 flex min-w-0 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-xs leading-4 text-destructive"
|
||||
detail="Installation couldn’t be completed. Try again."
|
||||
detail={installError}
|
||||
label="Installation failed"
|
||||
showIcon
|
||||
testId={`onboarding-runtime-error-${runtime.id}`}
|
||||
@@ -560,34 +597,6 @@ function RuntimeProvidersSection({
|
||||
}) {
|
||||
const { errorMessage, isChecking, items } = runtimeProviders;
|
||||
const orderedItems = getVisibleOnboardingRuntimes(items);
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
|
||||
function handleInstall(runtimeId: string) {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtimeId]: { error: null, success: false },
|
||||
}));
|
||||
|
||||
installMutation.mutate(runtimeId, {
|
||||
onSuccess: (result) => {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtimeId]: result.success
|
||||
? { error: null, success: true }
|
||||
: { error: getInstallErrorMessage(result.steps), success: false },
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
onInstallResultsChange((current) => ({
|
||||
...current,
|
||||
[runtimeId]: {
|
||||
error: error instanceof Error ? error.message : "Install failed.",
|
||||
success: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-full w-full flex-col items-center">
|
||||
@@ -606,13 +615,9 @@ function RuntimeProvidersSection({
|
||||
<div className="grid min-w-0 w-full max-w-[592px] grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{orderedItems.map((runtime) => (
|
||||
<RuntimeCard
|
||||
installError={installResults[runtime.id]?.error ?? null}
|
||||
isInstalling={
|
||||
installMutation.isPending &&
|
||||
installMutation.variables === runtime.id
|
||||
}
|
||||
installResults={installResults}
|
||||
key={runtime.id}
|
||||
onInstall={() => handleInstall(runtime.id)}
|
||||
onInstallResultsChange={onInstallResultsChange}
|
||||
runtime={runtime}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -279,22 +279,56 @@ function RuntimeHeader({
|
||||
}
|
||||
|
||||
function RuntimeRow({
|
||||
installError,
|
||||
installSuccess,
|
||||
isInstalling,
|
||||
onInstall,
|
||||
resetEpoch,
|
||||
runtime,
|
||||
}: {
|
||||
installError: string | null;
|
||||
installSuccess: boolean;
|
||||
isInstalling: boolean;
|
||||
onInstall: () => void;
|
||||
resetEpoch: number;
|
||||
runtime: AcpRuntimeCatalogEntry;
|
||||
}) {
|
||||
const [terminalLaunchMethodId, setTerminalLaunchMethodId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false);
|
||||
// Each row owns its mutation instance so concurrent installs each track
|
||||
// their own isPending / result state independently.
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
const [installResult, setInstallResult] = React.useState<{
|
||||
success: boolean;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
// Clear stale install results when the parent triggers a catalog refresh
|
||||
// (Check again) — the runtime may now be healthy and stale failure state
|
||||
// would linger because keyed rows don't remount on refetch.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: resetEpoch is an intentional trigger only; its value is not consumed in the effect body
|
||||
React.useEffect(() => {
|
||||
setInstallResult(null);
|
||||
}, [resetEpoch]);
|
||||
const isInstalling = installMutation.isPending;
|
||||
const installError = installResult?.error ?? null;
|
||||
const installSuccess = installResult?.success ?? false;
|
||||
|
||||
function handleInstall() {
|
||||
setInstallResult(null);
|
||||
installMutation.mutate(runtime.id, {
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setInstallResult({ success: true, error: null });
|
||||
} else {
|
||||
setInstallResult({
|
||||
success: false,
|
||||
error: getInstallErrorMessage(result.steps),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setInstallResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Install failed.",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const canConnectAccount =
|
||||
runtime.availability === "available" &&
|
||||
runtime.authStatus.status === "logged_out";
|
||||
@@ -352,7 +386,7 @@ function RuntimeRow({
|
||||
setIsUpdateWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
onInstall();
|
||||
handleInstall();
|
||||
}}
|
||||
runtime={runtime}
|
||||
/>
|
||||
@@ -372,7 +406,10 @@ function RuntimeRow({
|
||||
</p>
|
||||
) : null}
|
||||
{installError ? (
|
||||
<p className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive">
|
||||
<p
|
||||
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
|
||||
data-testid={`doctor-runtime-install-error-${runtime.id}`}
|
||||
>
|
||||
{installError}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -410,7 +447,7 @@ function RuntimeRow({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onInstall}
|
||||
onClick={handleInstall}
|
||||
data-testid={`doctor-runtime-confirm-update-${runtime.id}`}
|
||||
>
|
||||
Update
|
||||
@@ -491,59 +528,9 @@ export function DoctorSettingsPanel() {
|
||||
[runtimesQuery.data],
|
||||
);
|
||||
const isRefreshing = runtimesQuery.isFetching;
|
||||
const installMutation = useInstallAcpRuntimeMutation();
|
||||
const [installResults, setInstallResults] = React.useState<
|
||||
Record<string, { success: boolean; error: string | null }>
|
||||
>({});
|
||||
// Per-runtime installing state: tracks which runtime IDs have an in-flight
|
||||
// install so concurrent installs each show their own spinner correctly.
|
||||
const [installingIds, setInstallingIds] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
function handleInstall(runtimeId: string) {
|
||||
// Clear any previous result for this runtime before retrying.
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[runtimeId]: { success: false, error: null },
|
||||
}));
|
||||
setInstallingIds((prev) => new Set(prev).add(runtimeId));
|
||||
|
||||
installMutation.mutate(runtimeId, {
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[runtimeId]: { success: true, error: null },
|
||||
}));
|
||||
} else {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[runtimeId]: {
|
||||
success: false,
|
||||
error: getInstallErrorMessage(result.steps),
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setInstallResults((prev) => ({
|
||||
...prev,
|
||||
[runtimeId]: {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Install failed.",
|
||||
},
|
||||
}));
|
||||
},
|
||||
onSettled: () => {
|
||||
setInstallingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(runtimeId);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
// Incremented each time the user clicks "Check again" so RuntimeRow
|
||||
// useEffect clears stale install results from before the refresh.
|
||||
const [resetEpoch, setResetEpoch] = React.useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -558,7 +545,7 @@ export function DoctorSettingsPanel() {
|
||||
<Button
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
setInstallResults({});
|
||||
setResetEpoch((e) => e + 1);
|
||||
void runtimesQuery.refetch();
|
||||
void gitBashQuery.refetch();
|
||||
}}
|
||||
@@ -598,11 +585,8 @@ export function DoctorSettingsPanel() {
|
||||
<div className="space-y-3" data-testid="doctor-runtime-list">
|
||||
{runtimes.map((runtime) => (
|
||||
<RuntimeRow
|
||||
installError={installResults[runtime.id]?.error ?? null}
|
||||
installSuccess={installResults[runtime.id]?.success ?? false}
|
||||
isInstalling={installingIds.has(runtime.id)}
|
||||
key={runtime.id}
|
||||
onInstall={() => handleInstall(runtime.id)}
|
||||
resetEpoch={resetEpoch}
|
||||
runtime={runtime}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -67,3 +67,47 @@ test("getInstallErrorMessage: failed step with empty stderr falls back to stdout
|
||||
]);
|
||||
assert.match(message, /some stdout output/);
|
||||
});
|
||||
|
||||
test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => {
|
||||
const hint = "Git Bash is required. Install it from git-scm.com.";
|
||||
const message = getInstallErrorMessage([
|
||||
{
|
||||
step: "shell",
|
||||
command: "bash -l -c 'npm install'",
|
||||
success: false,
|
||||
stdout: "",
|
||||
stderr: "bash: command not found",
|
||||
exitCode: 127,
|
||||
hint,
|
||||
},
|
||||
]);
|
||||
assert.ok(
|
||||
message.includes("\n\n"),
|
||||
"hint and step detail should be separated by a blank line",
|
||||
);
|
||||
assert.ok(message.startsWith(hint));
|
||||
});
|
||||
|
||||
test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => {
|
||||
const message = getInstallErrorMessage([
|
||||
{
|
||||
step: "node",
|
||||
command: "node --version",
|
||||
success: true,
|
||||
stdout: "v20.0.0",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
},
|
||||
{
|
||||
step: "adapter",
|
||||
command: "npm install -g @agentclientprotocol/claude-code-acp",
|
||||
success: false,
|
||||
stdout: "",
|
||||
stderr: "npm ERR! code E404",
|
||||
exitCode: 1,
|
||||
},
|
||||
]);
|
||||
assert.match(message, /Step "adapter" failed:/);
|
||||
assert.match(message, /npm ERR! code E404/);
|
||||
assert.doesNotMatch(message, /Step "node"/);
|
||||
});
|
||||
|
||||
@@ -187,6 +187,18 @@ type E2eConfig = {
|
||||
* Call N returns results[N]; when exhausted the last entry repeats.
|
||||
* Takes precedence over `installAcpRuntimeResult`. */
|
||||
installAcpRuntimeResults?: RawInstallRuntimeResult[];
|
||||
/** Per-runtime install configuration keyed by runtimeId.
|
||||
* When a runtimeId matches, its entry overrides the global
|
||||
* installAcpRuntime* fields for that specific runtime. */
|
||||
installAcpRuntimeByRuntime?: Record<
|
||||
string,
|
||||
{
|
||||
delayMs?: number;
|
||||
result?: RawInstallRuntimeResult;
|
||||
/** Call-order sequence — same semantics as installAcpRuntimeResults. */
|
||||
results?: RawInstallRuntimeResult[];
|
||||
}
|
||||
>;
|
||||
managedAgentPrereqs?: {
|
||||
acp?: MockCommandAvailability;
|
||||
mcp?: MockCommandAvailability;
|
||||
@@ -7075,6 +7087,8 @@ async function handleConnectAcpRuntime(
|
||||
// Per-page install call counter. Reset each test run because this module is
|
||||
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
|
||||
let installCallCount = 0;
|
||||
/** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */
|
||||
const installCallCountByRuntime: Record<string, number> = {};
|
||||
let addChannelMembersCallCount = 0;
|
||||
let mockGlobalAgentConfig: {
|
||||
env_vars: Record<string, string>;
|
||||
@@ -7095,6 +7109,31 @@ async function handleInstallAcpRuntime(
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawInstallRuntimeResult> {
|
||||
const runtimeId = args.runtimeId ?? "";
|
||||
const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId];
|
||||
|
||||
if (perRuntime) {
|
||||
const delayMs = perRuntime.delayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
const seq = perRuntime.results;
|
||||
if (seq && seq.length > 0) {
|
||||
const idx = Math.min(
|
||||
installCallCountByRuntime[runtimeId] ?? 0,
|
||||
seq.length - 1,
|
||||
);
|
||||
installCallCountByRuntime[runtimeId] = idx + 1;
|
||||
const result = seq[idx];
|
||||
if (result.success) mockInstallCompleted = true;
|
||||
return result;
|
||||
}
|
||||
if (perRuntime.result) {
|
||||
if (perRuntime.result.success) mockInstallCompleted = true;
|
||||
return perRuntime.result;
|
||||
}
|
||||
}
|
||||
|
||||
const delayMs = config?.mock?.installAcpRuntimeDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
|
||||
Reference in New Issue
Block a user