import { describe, expect, mock, test } from "bun:test";
import type { AssistantEvent } from "../api/index.js";
import type { SurfaceType } from "../daemon/message-protocol.js";
mock.module("../apps/app-store.js", () => ({
getApp: (id: string) => {
if (id !== "test-app") {
return null;
}
return {
id,
name: "Test App",
description: "A test app",
htmlDefinition: "Test App",
};
},
getAppPreview: () => null,
isDirectAppConversation: (app: { id: string }) => app.id === "test-app",
linkAppToConversationLineage: () => {},
listAppsByConversation: () => [
{
id: "inherited-app",
updatedAt: 2,
inheritedConversationIds: ["session-1"],
},
{ id: "test-app", updatedAt: 1 },
],
updateApp: () => {
throw new Error("updateApp should not be called in this test");
},
}));
import type { Conversation } from "../daemon/conversation.js";
import {
createSurfaceMutex,
handleSurfaceAction,
surfaceProxyResolver,
} from "../daemon/conversation-surfaces.js";
import { asConversation } from "./helpers/mock-conversation.js";
function makeContext(
emit: (msg: AssistantEvent) => void = () => {},
): Conversation {
return asConversation({
conversationId: "session-1",
emit,
pendingSurfaceActions: new Map(),
lastSurfaceAction: new Map<
string,
{ actionId: string; data?: Record }
>(),
surfaceState: new Map(),
surfaceUndoStacks: new Map(),
accumulatedSurfaceState: new Map(),
surfaceActionRequestIds: new Set(),
currentTurnSurfaces: [],
isProcessing: () => false,
enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
getQueueDepth: () => 0,
processMessage: async () => "ok",
withSurface: createSurfaceMutex(),
});
}
describe("starter task surface actions", () => {
test("forwards prompt payload as normal message content", () => {
const forwarded: string[] = [];
const ctx = makeContext();
ctx.processMessage = async (options) => {
forwarded.push(options.content);
return "ok";
};
ctx.pendingSurfaceActions.set("surf-1", { surfaceType: "dynamic_page" });
handleSurfaceAction(ctx, "surf-1", "relay_prompt", {
prompt: "Help me customize the app with a calmer palette.",
task: "change_look_and_feel",
});
expect(forwarded).toEqual([
"Help me customize the app with a calmer palette.",
]);
expect(ctx.pendingSurfaceActions.has("surf-1")).toBe(true);
});
test("falls back to human-readable summary with action data when prompt is absent", () => {
const forwarded: string[] = [];
const ctx = makeContext();
ctx.processMessage = async (options) => {
forwarded.push(options.content);
return "ok";
};
ctx.pendingSurfaceActions.set("surf-2", { surfaceType: "dynamic_page" });
handleSurfaceAction(ctx, "surf-2", "relay_prompt", {
topic: "weather in sf",
});
expect(forwarded).toHaveLength(1);
expect(forwarded[0]).toContain("[User action on dynamic_page surface:");
expect(forwarded[0]).toContain("Action data:");
expect(forwarded[0]).toContain('"topic":"weather in sf"');
expect(ctx.pendingSurfaceActions.has("surf-2")).toBe(true);
});
test("does not treat prompt-like fields as relay content for non-relay actions", () => {
const forwarded: string[] = [];
const ctx = makeContext();
ctx.processMessage = async (options) => {
forwarded.push(options.content);
return "ok";
};
ctx.pendingSurfaceActions.set("surf-3", { surfaceType: "dynamic_page" });
handleSurfaceAction(ctx, "surf-3", "save_filters", {
prompt: "keep this literal field",
});
expect(forwarded).toHaveLength(1);
expect(forwarded[0]).toContain("[User action on dynamic_page surface:");
expect(forwarded[0]).toContain('"prompt":"keep this literal field"');
});
test("consumes non-dynamic pending actions after forwarding", () => {
const ctx = makeContext();
ctx.pendingSurfaceActions.set("confirm-1", { surfaceType: "confirmation" });
handleSurfaceAction(ctx, "confirm-1", "confirm", {});
expect(ctx.pendingSurfaceActions.has("confirm-1")).toBe(false);
});
test("app_open registers dynamic_page surface as action-capable", async () => {
const sent: AssistantEvent[] = [];
const ctx = makeContext((msg) => sent.push(msg));
const result = await surfaceProxyResolver(ctx, "app_open", {
app_id: "test-app",
});
expect(result.isError).toBe(false);
const parsed = JSON.parse(String(result.content)) as {
surfaceId: string;
appId: string;
};
expect(parsed.appId).toBe("test-app");
expect(ctx.pendingSurfaceActions.get(parsed.surfaceId)?.surfaceType).toBe(
"dynamic_page",
);
expect(sent.some((msg) => msg.type === "ui_surface_show")).toBe(true);
});
test("app_open without app_id prefers a direct app over a newer inherited app", async () => {
const ctx = makeContext();
const result = await surfaceProxyResolver(ctx, "app_open", {});
expect(result.isError).toBe(false);
const parsed = JSON.parse(String(result.content)) as { appId: string };
expect(parsed.appId).toBe("test-app");
});
});