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
29 changes: 27 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10643,6 +10643,11 @@ function formatDiagnosticError(error: unknown): string {
}

function installRuntimeProcessErrorBoundary(label: string): () => void {
const rejectionWindowMs = 60_000;
const rejectionLogLimit = 5;
let rejectionWindowStart = 0;
let rejectionCount = 0;
let suppressedRejectionCount = 0;
const write = (kind: string, error: unknown): void => {
try {
process.stderr.write(`${label} contained ${kind}: ${formatDiagnosticError(error)}\n`);
Expand All @@ -10651,8 +10656,28 @@ function installRuntimeProcessErrorBoundary(label: string): () => void {
}
};
const onUnhandledRejection = (reason: unknown): void => {
write("fatal unhandled rejection", reason);
process.exit(1);
const now = Date.now();
if (now - rejectionWindowStart > rejectionWindowMs) {
if (suppressedRejectionCount > 0) {
write("unhandled rejection summary", `${suppressedRejectionCount} additional rejection(s) suppressed`);
}
rejectionWindowStart = now;
rejectionCount = 0;
suppressedRejectionCount = 0;
}
rejectionCount += 1;
// A single late async rejection must not tear down the project runtime:
// this process owns active Work chats, PTYs, and managed processes.
// JSON-RPC dispatch already returns per-request errors; anything that
// still reaches here is logged for diagnosis while the runtime stays up.
if (rejectionCount <= rejectionLogLimit) {
write("unhandled rejection", reason);
return;
}
suppressedRejectionCount += 1;
if (rejectionCount === rejectionLogLimit + 1) {
write("unhandled rejection rate limit", `suppressing additional rejections for ${rejectionWindowMs}ms`);
}
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const onUncaughtException = (error: Error): void => {
write("fatal uncaught exception", error);
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ describe("ADE_ACTION_ALLOWLIST shape", () => {
expect(actions).toContain("getDelta");
});

it("exposes computer_use_artifacts.readArtifactPreview for runtime-backed proof previews", () => {
it("exposes computer-use backend status and artifact preview reads for runtime-backed proof flows", () => {
const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? [];
expect(actions).toContain("getBackendStatus");
expect(actions).toContain("readArtifactPreview");
});

Expand Down Expand Up @@ -687,9 +688,13 @@ describe("runtime session actions", () => {
});

describe("runtime computer-use artifact actions", () => {
it("exposes artifact preview reads from the broker", async () => {
it("exposes backend status and artifact preview reads from the broker", async () => {
const backendStatus = {
backends: [],
localFallback: { available: true, detail: "available", supportedKinds: ["screenshot"] },
};
const broker = {
getBackendStatus: vi.fn(),
getBackendStatus: vi.fn(() => backendStatus),
ingest: vi.fn(),
listArtifacts: vi.fn(),
readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"),
Expand All @@ -700,11 +705,15 @@ describe("runtime computer-use artifact actions", () => {
computerUseArtifactBrokerService: broker,
} as unknown as Parameters<typeof getAdeActionDomainServices>[0];
const artifactService = getAdeActionDomainServices(runtime).computer_use_artifacts as {
getBackendStatus: () => unknown;
readArtifactPreview: (args: { uri: string }) => Promise<string | null>;
} & Record<string, unknown>;

expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("getBackendStatus");
expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("readArtifactPreview");
expect(artifactService.getBackendStatus()).toBe(backendStatus);
await expect(artifactService.readArtifactPreview({ uri: ".ade/artifacts/a.png" })).resolves.toBe("data:image/png;base64,AAAA");
expect(broker.getBackendStatus).toHaveBeenCalledTimes(1);
expect(broker.readArtifactPreview).toHaveBeenCalledWith({ uri: ".ade/artifacts/a.png" });
});
});
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
layout: ["get", "set"],
tiling_tree: ["get", "set"],
graph_state: ["get", "set"],
computer_use_artifacts: ["getOwnerSnapshot", "ingest", "listArtifacts", "readArtifactPreview", "routeArtifact", "updateArtifactReview"],
computer_use_artifacts: ["getOwnerSnapshot", "getBackendStatus", "ingest", "listArtifacts", "readArtifactPreview", "routeArtifact", "updateArtifactReview"],
ios_simulator: ["getStatus", "claim", "listDevices", "listLaunchTargets", "launch", "attachToChatSession", "shutdown", "screenshot", "getScreenSnapshot", "getInspectorSnapshot", "inspectPoint", "getPreviewCapability", "listPreviewTargets", "renderPreview", "openPreviewWorkspace", "startStream", "stopStream", "getStreamStatus", "tap", "typeText", "drag", "swipe", "selectPoint"],
app_control: ["getStatus", "claim", "launch", "launchInTerminal", "connect", "stop", "focusWindow", "minimizeWindow", "screenshot", "getSnapshot", "inspectPoint", "selectPoint", "click", "typeText", "scroll", "dispatchKey", "listTargets", "attachToTarget", "readTerminal", "writeTerminal", "signalTerminal"],
built_in_browser: [...BUILT_IN_BROWSER_DESKTOP_BRIDGE_METHODS],
Expand Down
187 changes: 187 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,8 @@ describe("buildComputerUseDirective", () => {
expect(result).not.toBeNull();
expect(result).toContain("Computer Use");
expect(result).toContain("get_computer_use_backend_status");
expect(result).toContain("If it is not exposed, do not stall");
expect(result).toContain("Respect the backend the user requested");
});

it("includes Ghost OS section when Ghost OS backend is available", () => {
Expand Down Expand Up @@ -11876,6 +11878,191 @@ describe("createAgentChatService", () => {
expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull();
});

it("does not emit a visible Codex goal-clear event when no goal was known", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});

await service.sendMessage({
sessionId: session.id,
text: "Start a normal turn.",
}, { awaitDispatch: true });
events.length = 0;

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "thread/goal/cleared",
params: { threadId: "thread-1" },
});
await new Promise((resolve) => setTimeout(resolve, 0));

expect(events.some((event) => event.event.type === "codex_goal_cleared")).toBe(false);
expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull();
});

it("emits a Codex goal-clear event when a known goal is cleared by app-server", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});
await service.setCodexGoal({
sessionId: session.id,
objective: "Ship CLI parity",
});
events.length = 0;

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "thread/goal/cleared",
params: { threadId: "thread-1" },
});

await waitForEvent(
events,
(event): event is AgentChatEventEnvelope =>
event.event.type === "codex_goal_cleared",
);
expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull();
});

it("deduplicates repeated Codex goal updates while retaining latest usage state", async () => {
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});

await service.sendMessage({
sessionId: session.id,
text: "Start working.",
}, { awaitDispatch: true });
events.length = 0;

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "thread/goal/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
goal: {
objective: "Ship CLI parity",
status: "active",
tokenBudget: null,
tokensUsed: 25,
updatedAt: 1_760_000_001,
},
},
});
await waitForEvent(
events,
(event): event is AgentChatEventEnvelope =>
event.event.type === "codex_goal_updated"
&& event.event.goal?.objective === "Ship CLI parity",
);
events.length = 0;

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "thread/goal/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
goal: {
objective: "Ship CLI parity",
status: "active",
tokenBudget: null,
tokensUsed: 50,
timeUsedSeconds: 12,
updatedAt: 1_760_000_002,
},
},
});
await new Promise((resolve) => setTimeout(resolve, 0));

expect(events.some((event) => event.event.type === "codex_goal_updated")).toBe(false);
expect((await service.getSessionSummary(session.id))?.codexGoal).toMatchObject({
objective: "Ship CLI parity",
status: "active",
tokenBudget: null,
tokensUsed: 50,
timeUsedSeconds: 12,
});

mockState.emitCodexPayload({
jsonrpc: "2.0",
method: "thread/goal/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
goal: {
objective: "Ship CLI parity",
status: "paused",
tokenBudget: null,
tokensUsed: 51,
updatedAt: 1_760_000_003,
},
},
});

await waitForEvent(
events,
(event): event is AgentChatEventEnvelope =>
event.event.type === "codex_goal_updated"
&& event.event.goal?.status === "paused",
);
});

it("refreshes a missing Codex goal without emitting a misleading goal-update chip", async () => {
mockState.codexResponseOverrides.set("thread/goal/set", (payload) => {
const params = payload.params as Record<string, unknown>;
return {
goal: {
objective: params.objective,
status: "active",
tokenBudget: null,
},
};
});
mockState.codexResponseOverrides.set("thread/goal/get", () => ({
goal: null,
}));
const events: AgentChatEventEnvelope[] = [];
const { service } = createService({
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
});
const session = await service.createSession({
laneId: "lane-1",
provider: "codex",
model: "gpt-5.5",
});
await service.setCodexGoal({
sessionId: session.id,
objective: "Ship CLI parity",
});
events.length = 0;

await expect(service.getCodexGoal({ sessionId: session.id })).resolves.toBeNull();

expect(events.some((event) => event.event.type === "codex_goal_updated")).toBe(false);
expect(events.some((event) => event.event.type === "codex_goal_cleared")).toBe(false);
expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull();
});

it("clears persisted Codex goals after restart by resuming the thread first", async () => {
mockState.codexResponseOverrides.set("thread/goal/set", (payload) => {
const params = payload.params as Record<string, unknown>;
Expand Down
Loading
Loading