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
13 changes: 13 additions & 0 deletions docs/hooks/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,19 @@ If a value is too large for the environment, it may be omitted (not set). Mux al

</details>

<details>
<summary>heartbeat (5)</summary>

| Env var | JSON path | Type | Description |
| ----------------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MUX_TOOL_INPUT_ACTION` | `action` | enum | Operation to perform: "get" reads the current heartbeat, "set" enables or configures it, and "unset" removes this workspace's heartbeat settings. |
| `MUX_TOOL_INPUT_CONTEXT_MODE` | `contextMode` | enum | set: context preparation for heartbeat turns: "normal" uses current context, "compact" compacts first, and "reset" appends a reset boundary first. Omit to preserve the current mode. |
| `MUX_TOOL_INPUT_ENABLED` | `enabled` | boolean | set: whether scheduled heartbeats are enabled. Omit to preserve the current value; when creating new settings, omitted means "enabled". |
| `MUX_TOOL_INPUT_INTERVAL_MS` | `intervalMs` | number | set: heartbeat interval in milliseconds (300000–86400000). Omit to preserve the current interval or use the global default for new settings. |
| `MUX_TOOL_INPUT_MESSAGE` | `message` | string | set: optional custom instruction body appended after the fixed idle-workspace lead-in. Pass an empty string to clear the custom message. |

</details>

<details>
<summary>memory (11)</summary>

Expand Down
27 changes: 10 additions & 17 deletions src/browser/contexts/WorkspaceContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,9 @@ import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout";

import type { APIClient } from "@/browser/contexts/API";
import { APIProvider, type APIClient } from "@/browser/contexts/API";

// Mock API
let currentClientMock: RecursivePartial<APIClient> = {};
void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({
api: currentClientMock as APIClient,
status: "connected" as const,
error: null,
}),
APIProvider: ({ children }: { children: React.ReactNode }) => children,
}));

// Helper to create test workspace metadata with default runtime config
const createWorkspaceMetadata = (
Expand Down Expand Up @@ -1490,13 +1481,15 @@ async function setupWithProjectContext() {
}

render(
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<ContextCapture />
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
<APIProvider client={currentClientMock as APIClient}>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
<ContextCapture />
</WorkspaceProvider>
</ProjectProvider>
</RouterProvider>
</APIProvider>
);

// Inject client immediately to handle race conditions where effects run before store update.
Expand Down
95 changes: 43 additions & 52 deletions src/browser/hooks/useWorkspaceHeartbeat.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import * as APIModule from "@/browser/contexts/API";
import * as WorkspaceContextModule from "@/browser/contexts/WorkspaceContext";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import type React from "react";

import { APIProvider, type APIClient } from "@/browser/contexts/API";
import {
WorkspaceContext,
type WorkspaceContext as WorkspaceContextValue,
} from "@/browser/contexts/WorkspaceContext";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { HeartbeatFormSettings } from "./useWorkspaceHeartbeat";
import { installDom } from "../../../tests/ui/dom";
import { useWorkspaceHeartbeat, type HeartbeatFormSettings } from "./useWorkspaceHeartbeat";

interface HeartbeatApi {
workspace: {
Expand All @@ -29,39 +34,33 @@ const TEST_WORKSPACE_ID = "workspace-1";
type WorkspaceMetadataMap = Map<string, FrontendWorkspaceMetadata>;
type WorkspaceMetadataUpdater = (prev: WorkspaceMetadataMap) => WorkspaceMetadataMap;

let apiMock: HeartbeatApi | null = null;
let capturedWorkspaceMetadataUpdate: WorkspaceMetadataUpdater | null = null;
const setWorkspaceMetadataMock = mock((update: WorkspaceMetadataUpdater) => {
capturedWorkspaceMetadataUpdate = update;
});

const actualAPIModule = { ...APIModule };
const actualWorkspaceContextModule = { ...WorkspaceContextModule };

// Keep module mocks inside test hooks: Bun loads test files before afterAll runs, so
// file-scope mock.module() calls can pollute unrelated files during collection.
async function installWorkspaceHeartbeatModuleMocks() {
await mock.module("@/browser/contexts/API", () => ({
...actualAPIModule,
useAPI: () => ({ api: apiMock }),
}));
await mock.module("@/browser/contexts/WorkspaceContext", () => ({
...actualWorkspaceContextModule,
useWorkspaceActions: () => ({
// Use real providers instead of mock.module(): Bun runs test files in one process, so
// module-level API/context mocks can leak into unrelated hook tests.
function createWrapper(api: HeartbeatApi): React.FC<{ children: React.ReactNode }> {
return function Wrapper(props) {
const workspaceContext = {
workspaceMetadata: new Map<string, FrontendWorkspaceMetadata>(),
loading: false,
loaded: true,
loadError: null,
setWorkspaceMetadata: setWorkspaceMetadataMock,
}),
}));
}

async function restoreWorkspaceHeartbeatModuleMocks() {
// Bun 1.3.6's mock.module() has no disposer, and mock.restore() does not undo
// module mocks. Restore the real exports so these stubs do not leak into later files.
await mock.module("@/browser/contexts/API", () => actualAPIModule);
await mock.module("@/browser/contexts/WorkspaceContext", () => actualWorkspaceContextModule);
} as unknown as WorkspaceContextValue;

return (
<APIProvider client={api as unknown as APIClient}>
<WorkspaceContext.Provider value={workspaceContext}>
{props.children}
</WorkspaceContext.Provider>
</APIProvider>
);
};
}

import { useWorkspaceHeartbeat } from "./useWorkspaceHeartbeat";

function createMetadata(
overrides: Partial<FrontendWorkspaceMetadata> = {}
): FrontendWorkspaceMetadata {
Expand Down Expand Up @@ -96,36 +95,24 @@ function applyCapturedMetadataUpdate(
}

describe("useWorkspaceHeartbeat", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;
let cleanupDom: (() => void) | null = null;

afterAll(async () => {
await restoreWorkspaceHeartbeatModuleMocks();
});

beforeEach(async () => {
originalWindow = globalThis.window;
originalDocument = globalThis.document;
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
await installWorkspaceHeartbeatModuleMocks();
beforeEach(() => {
cleanupDom = installDom();
capturedWorkspaceMetadataUpdate = null;
setWorkspaceMetadataMock.mockClear();
});

afterEach(async () => {
afterEach(() => {
cleanup();
await restoreWorkspaceHeartbeatModuleMocks();
mock.restore();
apiMock = null;
capturedWorkspaceMetadataUpdate = null;
globalThis.window = originalWindow;
globalThis.document = originalDocument;
cleanupDom?.();
cleanupDom = null;
});

test("optimistically enables heartbeat metadata after a successful save", async () => {
const saveHeartbeat = mock(() => Promise.resolve({ success: true }));
apiMock = {
const api: HeartbeatApi = {
workspace: {
heartbeat: {
get: () => Promise.resolve(null),
Expand All @@ -137,7 +124,9 @@ describe("useWorkspaceHeartbeat", () => {
},
};

const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }));
const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), {
wrapper: createWrapper(api),
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
Expand Down Expand Up @@ -182,7 +171,7 @@ describe("useWorkspaceHeartbeat", () => {
contextMode: "compact",
message: "Keep watching",
};
apiMock = {
const api: HeartbeatApi = {
workspace: {
heartbeat: {
get: () => Promise.resolve(initialSettings),
Expand All @@ -194,7 +183,9 @@ describe("useWorkspaceHeartbeat", () => {
},
};

const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }));
const { result } = renderHook(() => useWorkspaceHeartbeat({ workspaceId: TEST_WORKSPACE_ID }), {
wrapper: createWrapper(api),
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
Expand Down
1 change: 1 addition & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ function buildExperimentsObject(experimentIds: string[]): SendMessageOptions["ex
execSubagentHardRestart: experimentIds.includes("exec-subagent-hard-restart"),
dynamicWorkflows: experimentIds.includes("dynamic-workflows"),
subagentFileReports: experimentIds.includes("subagent-file-reports"),
workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/cli/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ function buildExperimentsObject(experimentIds: readonly string[]) {
// experiment is enabled implicitly for this invocation (never persisted).
dynamicWorkflows: true,
subagentFileReports: experimentIds.includes(EXPERIMENT_IDS.SUBAGENT_FILE_REPORTS),
workspaceHeartbeats: experimentIds.includes(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS),
};
}

Expand Down
1 change: 1 addition & 0 deletions src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ export const ExperimentsSchema = z.object({
subagentFileReports: z.boolean().optional(),
execSubagentHardRestart: z.boolean().optional(),
memory: z.boolean().optional(),
workspaceHeartbeats: z.boolean().optional(),
Comment thread
ThomasK33 marked this conversation as resolved.
});

/**
Expand Down
5 changes: 5 additions & 0 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
MuxAgentsReadToolResultSchema,
MuxAgentsWriteToolResultSchema,
FileReadToolResultSchema,
HeartbeatToolResultSchema,
MemoryToolResultSchema,
AttachFileToolResultSchema,
TaskToolResultSchema,
Expand Down Expand Up @@ -133,6 +134,10 @@ export interface ToolOutputUiOnlyFields {
// FileReadToolResult derived from Zod schema (single source of truth)
export type FileReadToolResult = z.infer<typeof FileReadToolResultSchema>;

// Heartbeat tool types, derived from schema (avoid drift)
export type HeartbeatToolArgs = z.infer<typeof TOOL_DEFINITIONS.heartbeat.schema>;
export type HeartbeatToolResult = z.infer<typeof HeartbeatToolResultSchema>;

// Memory tool types, derived from schema (avoid drift)
export type MemoryToolArgs = z.infer<typeof TOOL_DEFINITIONS.memory.schema>;
export type MemoryToolResult = z.infer<typeof MemoryToolResultSchema>;
Expand Down
29 changes: 29 additions & 0 deletions src/common/utils/tools/toolDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,29 @@ describe("TOOL_DEFINITIONS", () => {
}
});

it("validates heartbeat tool configuration bounds", () => {
expect(TOOL_DEFINITIONS.heartbeat.schema.safeParse({ action: "get" }).success).toBe(true);
expect(
TOOL_DEFINITIONS.heartbeat.schema.safeParse({
action: "set",
enabled: true,
intervalMs: 5 * 60 * 1000,
contextMode: "compact",
}).success
).toBe(true);
expect(
TOOL_DEFINITIONS.heartbeat.schema.safeParse({
action: "set",
intervalMs: 60 * 1000,
}).success
).toBe(false);
expect(
TOOL_DEFINITIONS.heartbeat.schema.safeParse({
action: "configure",
}).success
).toBe(false);
});

it("requires complete_goal summary", () => {
expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({}).success).toBe(false);
expect(TOOL_DEFINITIONS.complete_goal.schema.safeParse({ summary: "Done." }).success).toBe(
Expand Down Expand Up @@ -443,6 +466,12 @@ describe("TOOL_DEFINITIONS", () => {
expect(tools).toContain("skills_catalog_read");
});

it("includes the workspace heartbeat tool", () => {
const tools = getAvailableTools("openai:gpt-4o");

expect(tools).toContain("heartbeat");
});

it("only includes Review pane tools when enableReviewPane is not disabled", () => {
const defaultTools = getAvailableTools("openai:gpt-4o");
expect(defaultTools).toContain("review_pane_update");
Expand Down
Loading
Loading