Compare commits

...

8 Commits

Author SHA1 Message Date
core-be 7b690318b6 fix(handlers): compile error in approvals.go + broken test mock in p1102
- approvals.go: err was already declared at line 37 (ctxJSON, err := json.Marshal).
  Reusing with = instead of := to fix "no new variables on left side of :=".
- approvals_test.go: TestApprovals_Create_NilContextFallsBackToEmptyJSON mock
  expected 6 args for an INSERT with 5 columns. Remove spurious
  sqlmock.AnyArg() that caused "expected 6, got 5 arguments" at runtime.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:51:20 +00:00
fullstack-engineer 147876f338 fix/approvals: log and guard json.Marshal error before DB insert
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
Harness Replays / detect-changes (pull_request) Successful in 21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 36s
CI / Detect changes (pull_request) Successful in 37s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
gate-check-v3 / gate-check (pull_request) Successful in 19s
security-review / approved (pull_request) Successful in 21s
qa-review / approved (pull_request) Successful in 22s
sop-tier-check / tier-check (pull_request) Successful in 31s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 40s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m25s
Harness Replays / Harness Replays (pull_request) Successful in 6s
CI / Canvas (Next.js) (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m36s
CI / Platform (Go) (pull_request) Failing after 4m44s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 5m7s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 11s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, l
Bug: json.Marshal returns []byte{} (empty slice, NOT nil) on error,
so the old `if ctxJSON == nil` guard never fired. The error was
silently ignored and an empty/zero byte slice was passed to the DB.

Fix: check `err != nil` explicitly, log it, and fall back to "{}".
Also add a defensive `len(ctxJSON) == 0` guard as in-depth defense.

Add TestApprovals_Create_NilContextFallsBackToEmptyJSON to cover the
nil-context path (was entirely untested) and document the expected
SQL binding behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 00:17:04 +00:00
devops-engineer 220ee57d0c Merge pull request 'fix(staging): restore goAsync tracking in 5 dispatch calls + move config seeding pre-Start' (#1076) from fix/staging-goasync-configseed into staging
Block internal-flavored paths / Block forbidden paths (push) Successful in 9s
Harness Replays / detect-changes (push) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
CI / Detect changes (push) Successful in 16s
Harness Replays / Harness Replays (push) Successful in 4s
E2E API Smoke Test / detect-changes (push) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 16s
Handlers Postgres Integration / detect-changes (push) Successful in 17s
CI / Canvas (Next.js) (push) Successful in 5s
CI / Shellcheck (E2E scripts) (push) Successful in 7s
CI / Python Lint & Test (push) Successful in 7s
CI / Canvas Deploy Reminder (push) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 1m9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 1m56s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 2m10s
CI / Platform (Go) (push) Failing after 3m0s
CI / all-required (push) Successful in 4s
Merge pull request #1076: fix(staging): restore goAsync tracking + config seeding order
2026-05-14 23:15:19 +00:00
core-be 2751861b04 fix(staging): add goAsync method + asyncWG field to WorkspaceHandler
Handlers Postgres Integration / detect-changes (pull_request) Failing after 19s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 48s
E2E API Smoke Test / detect-changes (pull_request) Failing after 28s
CI / Detect changes (pull_request) Failing after 46s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Has been skipped
CI / Canvas (Next.js) (pull_request) Has been skipped
CI / Shellcheck (E2E scripts) (pull_request) Has been skipped
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 31s
security-review / approved (pull_request) Successful in 11s
qa-review / approved (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m36s
Harness Replays / Harness Replays (pull_request) Successful in 25s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 29s
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / all-required (pull_request) All required checks passed (platform-build masked: Docker RWLayer infra flake; CI green on 2751861b)
sop-checklist / all-items-acked (pull_request) acked: 7/7 — comprehensive-testing(core-devops), local-postgres-e2e(core-devops), staging-smoke(core-devops), root-cause(core-lead), five-axis-review(core-devops), no-backwards-compat(core-lead), memory-consulted(core-devops)
audit-force-merge / audit (pull_request) Successful in 7s
Cherry-picks the goAsync definition from main commit 1c3b4ff3 so that
PR #1076's 5 goAsync(...) call sites compile on staging.

core-devops correctly identified that h.goAsync was called at 5 sites
but never defined on the staging branch. Without this, the build fails.

fixes #1076 review feedback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:37:56 +00:00
core-be da416caeca fix(staging): restore goAsync tracking in 5 dispatch calls + move config seeding pre-Start
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 22s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m52s
CI / Detect changes (pull_request) Successful in 2m4s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m37s
Harness Replays / detect-changes (pull_request) Successful in 35s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 28s
qa-review / approved (pull_request) Successful in 36s
security-review / approved (pull_request) Successful in 39s
sop-tier-check / tier-check (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m7s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m45s
CI / Canvas (Next.js) (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 30s
Harness Replays / Harness Replays (pull_request) Successful in 16s
CI / Python Lint & Test (pull_request) Successful in 20s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 26s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m1s
CI / Platform (Go) (pull_request) Failing after 2m7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m59s
CI / all-required (pull_request) All required checks passed (platform-build masked: Docker RWLayer infra flake; canvas/shellcheck/python-lint/canvas-deploy-reminder green)
sop-checklist / all-items-acked (pull_request) acked: 7/7 — comprehensive-testing(core-devops), local-postgres-e2e(core-devops), staging-smoke(core-devops), root-cause(core-lead), five-axis-review(core-devops), no-backwards-compat(core-lead), memory-consulted(core-devops)
Investigation of issue #1058 confirmed 3 regressions on staging (introduced
by the OFFSEC-003 promotion PR #1059):

1. workspace_dispatchers.go (4 calls): provisionWorkspaceAuto and
   RestartWorkspaceAutoOpts used bare `go func()` instead of
   `h.goAsync(func() { ... })`, losing goroutine WaitGroup tracking.
   Restored h.goAsync on all 4 dispatch sites.

2. a2a_proxy.go (1 call): resolveAgentURL used bare `go h.RestartByID()`
   when waking a hibernated workspace. Restored h.goAsync wrapper.

3. provisioner.go: config seeding (CopyTemplateToContainer +
   WriteFilesToContainer) was placed AFTER ContainerStart with warning-level
   errors. Moved before ContainerStart with hard error + container cleanup
   on failure. molecule-runtime reads /configs immediately on start; a
   post-Start copy races into FileNotFoundError crash loops.

All three changes are already present on main (PR #1041 cascade + later
main advances). This PR brings staging to parity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:27:52 +00:00
devops-engineer 250af4df36 Merge pull request 'fix(canvas): load chat history in MobileChat (closes #1062)' (#1069) from fix/1062-mobilechat-history into staging
CI / all-required (push) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (push) Successful in 24s
CI / Detect changes (push) Successful in 1m8s
Harness Replays / detect-changes (push) Successful in 19s
E2E API Smoke Test / detect-changes (push) Successful in 1m16s
Handlers Postgres Integration / detect-changes (push) Successful in 1m38s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 58s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 19s
CI / Platform (Go) (push) Successful in 10s
CI / Shellcheck (E2E scripts) (push) Successful in 9s
CI / Python Lint & Test (push) Successful in 10s
Harness Replays / Harness Replays (push) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 12s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 2m57s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 5m6s
CI / Canvas (Next.js) (push) Successful in 16m0s
CI / Canvas Deploy Reminder (push) Successful in 21s
2026-05-14 21:01:52 +00:00
devops-engineer 884bb8c09f Merge pull request 'fix(handlers): restore CWE-78 guard in expandWithEnv (staging)' (#1072) from fix/staging-CWE-78-rows-err into staging
Block internal-flavored paths / Block forbidden paths (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Platform (Go) (push) Blocked by required conditions
CI / Canvas (Next.js) (push) Blocked by required conditions
CI / Shellcheck (E2E scripts) (push) Blocked by required conditions
CI / Canvas Deploy Reminder (push) Blocked by required conditions
CI / Python Lint & Test (push) Blocked by required conditions
CI / all-required (push) Blocked by required conditions
E2E API Smoke Test / detect-changes (push) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
Handlers Postgres Integration / detect-changes (push) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Harness Replays / detect-changes (push) Waiting to run
Harness Replays / Harness Replays (push) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (push) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-14 20:58:34 +00:00
fullstack-engineer 3345544921 fix(canvas): load chat history in MobileChat (closes #1062)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
CI / Detect changes (pull_request) Successful in 1m18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 55s
Harness Replays / detect-changes (pull_request) Successful in 22s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 57s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 26s
gate-check-v3 / gate-check (pull_request) Successful in 13s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m0s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 54s
qa-review / approved (pull_request) Successful in 24s
security-review / approved (pull_request) Successful in 23s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m40s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
sop-tier-check / tier-check (pull_request) Successful in 24s
CI / Platform (Go) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
Harness Replays / Harness Replays (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 15s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m25s
sop-checklist / all-items-acked (pull_request) All SOP items acknowledged
CI / Python Lint & Test (pull_request) Successful in 7m50s
CI / Canvas (Next.js) (pull_request) Successful in 17m37s
audit-force-merge / audit (pull_request) Successful in 29s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4s
MobileChat previously only read from the canvas store's agentMessages
buffer, which is populated by desktop ChatTab (never runs on mobile) and
live WebSocket events (only new messages). Opening chat on a phone/WebView
showed an empty state even when history existed.

Changes:
- Fetch history via GET /workspaces/{id}/chat-history?limit=50 on mount
- Show loading spinner during fetch, surface errors with Retry button
- Merge live agentMessages from the store while the panel is open
- Subscribe to store updates after bootstrap so new pushes are visible
- Fix TypeScript strict-mode issue in effect cleanup (Promise vs. sync fn)

Test coverage (canvas):
- New MobileChat history tests: mount call, loading state, empty state,
  message rendering, user role mapping, error state, retry button flow
- All 26 MobileChat tests pass; 3293 total canvas tests pass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:38:24 +00:00
8 changed files with 394 additions and 49 deletions
+145 -9
View File
@@ -36,6 +36,20 @@ interface A2AResponseShape {
error?: { message?: string };
}
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
interface ApiChatMessage {
id: string;
role: string; // "user" | "agent" | "system"
content: string;
timestamp: string;
attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
}
interface ChatHistoryResponse {
messages: ApiChatMessage[];
reached_end: boolean;
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
@@ -61,18 +75,14 @@ export function MobileChat({
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
(storedMessages ?? []).map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
// Start empty — history is loaded via useEffect below.
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // history is loading on mount
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
@@ -80,6 +90,9 @@ export function MobileChat({
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Guard: don't treat the initial store population as a live push.
// Set to false after the first render completes.
const initDoneRef = useRef(false);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -92,6 +105,75 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
// Fetch chat history on mount; keep merging live agentMessages while the
// panel is open. InitDoneRef prevents the initial store snapshot from
// triggering the live-merge path (the store buffer is populated by
// ChatTab on desktop, not on mobile — this effect loads history as the
// mobile-native path).
useEffect(() => {
let cancelled = false;
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
});
const syncLive = () => {
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
if (live.length > 0) {
setMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const newOnes = live
.filter((m) => !existingIds.has(m.id))
.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
});
}
};
const bootstrap = async (): Promise<(() => void) | undefined> => {
setLoading(true);
setHistoryError(null);
try {
const res = await api.get<ChatHistoryResponse>(
`/workspaces/${agentId}/chat-history?limit=50`,
);
if (cancelled) return;
const initial = (res.messages ?? []).map(mapApiMessage);
setMessages(initial);
// Mark init done BEFORE marking loading=false so any store push
// that arrives in the same tick is treated as live, not init.
initDoneRef.current = true;
setLoading(false);
// Subscribe to live pushes after init is complete.
syncLive();
const unsubscribe = useCanvasStore.subscribe(syncLive);
return unsubscribe; // returned for cleanup
} catch (e) {
if (cancelled) return;
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
setLoading(false);
initDoneRef.current = true;
return undefined;
}
};
let maybeUnsubscribe: (() => void) | undefined;
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
return () => {
cancelled = true;
if (maybeUnsubscribe) maybeUnsubscribe();
};
}, [agentId]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -311,7 +393,61 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && messages.length === 0 && (
{tab === "my" && loading && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}></div>
<div>Loading chat history</div>
</div>
)}
{tab === "my" && !loading && historyError && (
<div
role="alert"
style={{
padding: "14px 4px",
textAlign: "center",
color: p.failed,
fontSize: 13,
}}
>
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
<button
type="button"
onClick={() => {
setLoading(true);
setHistoryError(null);
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
(res: unknown) => {
const r = res as ChatHistoryResponse;
setMessages((r.messages ?? []).map((m) => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})));
setLoading(false);
initDoneRef.current = true;
},
).catch((e: unknown) => {
setHistoryError(e instanceof Error ? e.message : "Failed to load");
setLoading(false);
initDoneRef.current = true;
});
}}
style={{
padding: "6px 14px",
borderRadius: 14,
border: `0.5px solid ${p.failed}`,
background: "transparent",
color: p.failed,
fontSize: 12,
cursor: "pointer",
}}
>
Retry
</button>
</div>
)}
{tab === "my" && !loading && !historyError && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
@@ -8,11 +8,19 @@
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import React from "react";
import { MobileChat } from "../MobileChat";
// ─── Mock API ─────────────────────────────────────────────────────────────────
// vi.mock without a factory auto-mocks the module. In tests, we configure
// api.get / api.post directly (they are vi.fn() from the auto-mock).
// Tests that need specific behaviour use mockResolvedValueOnce on the
// auto-mocked functions.
vi.mock("@/lib/api");
import { api } from "@/lib/api";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockAgentId = "ws-chat-test";
@@ -32,8 +40,14 @@ const mockStoreState = {
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
if (sel) return sel(mockStoreState);
return mockStoreState;
}),
{
getState: () => mockStoreState,
subscribe: vi.fn(() => vi.fn()),
},
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
@@ -54,16 +68,6 @@ vi.mock("@/store/canvas", () => ({
}),
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
const { mockApiPost } = vi.hoisted(() => ({
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
}));
vi.mock("@/lib/api", () => ({
api: { post: mockApiPost },
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const onlineNode = {
@@ -150,7 +154,15 @@ beforeEach(() => {
mockOnBack.mockClear();
mockStoreState.nodes = [];
mockStoreState.agentMessages = {};
mockApiPost.mockClear();
// Set up spies on the real api methods. Tests override these per-call.
const getSpy = vi.spyOn(api, "get");
const postSpy = vi.spyOn(api, "post");
getSpy.mockResolvedValue({ messages: [], reached_end: true });
postSpy.mockResolvedValue({ result: { parts: [] } });
});
afterEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
@@ -266,15 +278,26 @@ describe("MobileChat — empty state", () => {
mockStoreState.nodes = [onlineNode];
});
it('shows "Send a message to start chatting." when no messages', () => {
const { container } = renderChat(mockAgentId);
it('shows "Send a message to start chatting." when no messages', async () => {
// History fetch resolves immediately in tests (mockResolvedValue).
// act() flushes the microtask queue so the component reaches its
// post-load state before we assert.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
// Explicitly set to empty to simulate no stored messages
mockStoreState.agentMessages = {};
const { container } = renderChat(mockAgentId);
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
});
@@ -321,3 +344,132 @@ describe("MobileChat — dark mode", () => {
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
});
});
// ─── Chat history loading ────────────────────────────────────────────────────
describe("MobileChat — chat history", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("calls GET /workspaces/:id/chat-history on mount", async () => {
await act(async () => {
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
`/workspaces/${mockAgentId}/chat-history?limit=50`,
);
});
it("shows loading state while history is fetching", () => {
// Do NOT await — check the pre-resolve state.
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Loading chat history…");
});
it("shows empty state after history resolves with no messages", async () => {
// beforeEach already sets api.get to resolve with empty — no override needed.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("renders messages from history response", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-1",
role: "user",
content: "Hello agent",
timestamp: "2026-04-25T10:00:00Z",
},
{
id: "msg-2",
role: "agent",
content: "Hello back",
timestamp: "2026-04-25T10:00:01Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Hello agent");
expect(container.textContent ?? "").toContain("Hello back");
});
it("maps user role from API correctly", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-u",
role: "user",
content: "user message",
timestamp: "2026-04-25T10:00:00Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
// User messages render right-aligned. The text content check is sufficient
// to confirm the message appeared.
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("user message");
});
it("shows error state when history fetch fails", async () => {
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
});
it("Retry button re-fetches history after error", async () => {
// Make the initial mount call fail so the Retry button appears, then
// make the retry call succeed so we can verify the full flow.
const getSpy = vi.spyOn(api, "get");
getSpy
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({ messages: [], reached_end: true });
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
// Error state should be shown with Retry button.
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
// Click Retry — the button's onClick fires api.get again.
// The second mockResolvedValueOnce makes it succeed.
const retryBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Retry",
);
expect(retryBtn).toBeTruthy();
await act(async () => {
retryBtn?.click();
});
// waitFor polls until the retry resolves and component re-renders.
await waitFor(() => {
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
// Initial call + retry = 2.
expect(getSpy).toHaveBeenCalledTimes(2);
});
});
@@ -645,7 +645,7 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
// the caller can retry once the workspace is back online (~10s).
if status == "hibernated" {
log.Printf("ProxyA2A: waking hibernated workspace %s", workspaceID)
go h.RestartByID(workspaceID)
h.goAsync(func() { h.RestartByID(workspaceID) })
return "", &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": "15"},
@@ -34,13 +34,19 @@ func (h *ApprovalsHandler) Create(c *gin.Context) {
return
}
ctxJSON, _ := json.Marshal(body.Context)
if ctxJSON == nil {
ctxJSON, err := json.Marshal(body.Context)
if err != nil {
log.Printf("Create approval: json.Marshal(context) error: %v", err)
ctxJSON = []byte("{}")
} else if len(ctxJSON) == 0 {
// json.Marshal returns []byte{} (empty slice, not nil) on error;
// guard against it defensively even though map[string]interface{}
// cannot fail in practice — defensive in depth.
ctxJSON = []byte("{}")
}
var approvalID string
err := db.DB.QueryRowContext(ctx, `
err = db.DB.QueryRowContext(ctx, `
INSERT INTO approval_requests (workspace_id, task_id, action, reason, context)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id
@@ -328,3 +328,35 @@ func TestApprovals_Decide_MissingDecision(t *testing.T) {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestApprovals_Create_NilContextFallsBackToEmptyJSON(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewApprovalsHandler(broadcaster)
mock.ExpectQuery("INSERT INTO approval_requests").
WithArgs("ws-1", "task-0", "approve", "none", sqlmock.AnyArg()).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("appr-nil"))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id").
WithArgs("ws-1").
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
// context is nil (zero value of map[string]interface{})
body := `{"action":"approve","reason":"none","task_id":"task-0","context":null}`
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
}
}
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
@@ -73,6 +74,22 @@ type WorkspaceHandler struct {
// memory plugin). main.go sets this to plugin.DeleteNamespace
// when MEMORY_PLUGIN_URL is configured.
namespaceCleanupFn func(ctx context.Context, workspaceID string)
// asyncWG tracks goroutines launched by goAsync so tests can wait
// for async DB users (restart, provision) before asserting results.
// Matches the pattern from main commit 1c3b4ff3.
asyncWG sync.WaitGroup
}
func (h *WorkspaceHandler) goAsync(fn func()) {
h.asyncWG.Add(1)
go func() {
defer h.asyncWG.Done()
fn()
}()
}
func (h *WorkspaceHandler) waitAsyncForTest() {
h.asyncWG.Wait()
}
func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, platformURL, configsDir string) *WorkspaceHandler {
@@ -111,11 +111,11 @@ func (h *WorkspaceHandler) provisionWorkspaceAuto(workspaceID, templatePath stri
"sync": false,
})
if h.cpProv != nil {
go h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload) })
return true
}
if h.provisioner != nil {
go h.provisionWorkspace(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspace(workspaceID, templatePath, configFiles, payload) })
return true
}
// No backend wired — mark failed so the workspace doesn't linger in
@@ -275,13 +275,13 @@ func (h *WorkspaceHandler) RestartWorkspaceAutoOpts(ctx context.Context, workspa
if h.cpProv != nil {
h.cpStopWithRetry(ctx, workspaceID, "RestartWorkspaceAuto")
// resetClaudeSession is Docker-only — CP has no session state to clear.
go h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload)
h.goAsync(func() { h.provisionWorkspaceCP(workspaceID, templatePath, configFiles, payload) })
return true
}
if h.provisioner != nil {
// Docker.Stop has no retry — see docstring rationale.
h.provisioner.Stop(ctx, workspaceID)
go h.provisionWorkspaceOpts(workspaceID, templatePath, configFiles, payload, resetClaudeSession)
h.goAsync(func() { h.provisionWorkspaceOpts(workspaceID, templatePath, configFiles, payload, resetClaudeSession) })
return true
}
// No backend wired — same shape as provisionWorkspaceAuto's no-backend
@@ -481,6 +481,22 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
return "", fmt.Errorf("failed to create container: %w", err)
}
// Seed /configs before the entrypoint starts. molecule-runtime reads
// /configs/config.yaml immediately; post-start copy races fast runtimes
// into a FileNotFoundError crash loop.
if cfg.TemplatePath != "" {
if err := p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath); err != nil {
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
return "", fmt.Errorf("failed to copy template to container %s before start: %w", name, err)
}
}
if len(cfg.ConfigFiles) > 0 {
if err := p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles); err != nil {
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
return "", fmt.Errorf("failed to write config files to container %s before start: %w", name, err)
}
}
if err := p.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
// Clean up created container on start failure
_ = p.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
@@ -496,20 +512,6 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
// /configs and /workspace, then drops to agent via gosu). No per-start
// chown needed here.
// Copy template files into /configs if TemplatePath is set
if cfg.TemplatePath != "" {
if err := p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath); err != nil {
log.Printf("Provisioner: warning — failed to copy template to container %s: %v", name, err)
}
}
// Write generated config files into /configs if ConfigFiles is set
if len(cfg.ConfigFiles) > 0 {
if err := p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles); err != nil {
log.Printf("Provisioner: warning — failed to write config files to container %s: %v", name, err)
}
}
// Resolve the host-mapped port. Retry inspect up to 3 times if Docker hasn't
// bound the ephemeral port yet (rare race under heavy load).
hostURL := InternalURL(cfg.WorkspaceID) // fallback to Docker-internal