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
30 changes: 30 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,36 @@ describe("ADE_ACTION_ALLOWLIST shape", () => {
expect(isCtoOnlyAdeAction("chat", "deletePromptStash")).toBe(true);
});

it("preserves prompt stash images through the runtime-backed action path", async () => {
const run = vi.fn();
const runtime = {
agentChatService: {},
db: {
get: vi.fn().mockReturnValue(undefined),
run,
},
} as unknown as Parameters<typeof getAdeActionDomainServices>[0];
const chat = getAdeActionDomainServices(runtime).chat as {
createPromptStash?: (args: unknown) => Promise<unknown> | unknown;
};
const attachment = {
path: "/project/.ade/attachments/design.png",
type: "image",
};

await expect(Promise.resolve(chat.createPromptStash?.({
text: "",
attachments: [attachment],
}))).resolves.toMatchObject({
text: "",
attachments: [attachment],
});
expect(run).toHaveBeenCalledWith(
expect.stringContaining("insert into prompt_stashes"),
expect.arrayContaining([JSON.stringify([attachment])]),
);
});

it("exposes Linear issue tracker composite reads for runtime-backed CTO views", () => {
const actions = ADE_ACTION_ALLOWLIST.linear_issue_tracker ?? [];
expect(actions).toContain("getWorkflowCatalog");
Expand Down
13 changes: 4 additions & 9 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1575,15 +1575,10 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null {
listPromptStashes: () => listPromptStashes(runtime.db),
createPromptStash: (args?: PromptStashCreateArgs) => {
const record = readObjectActionArg(args, "chat.createPromptStash");
const text = typeof record.text === "string" ? record.text : "";
if (!text.trim()) {
throw new Error("Expected 'text' to be a non-empty string.");
}
return createPromptStash(runtime.db, {
text,
provider: typeof record.provider === "string" ? record.provider : null,
modelId: typeof record.modelId === "string" ? record.modelId : null,
});
// The service owns validation for both text and attachment-only stashes.
// Keeping the full object intact is essential on the daemon path: this is
// the path every runtime-backed desktop uses.
return createPromptStash(runtime.db, record);
},
deletePromptStash: (args?: PromptStashDeleteArgs) => {
const record = readObjectActionArg(args, "chat.deletePromptStash");
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17122,6 +17122,32 @@ describe("createAgentChatService", () => {
expect(fs.existsSync(oldFile)).toBe(false);
expect(fs.existsSync(recentFile)).toBe(true);
});

it("preserves old image files still referenced by a prompt stash", () => {
const attachDir = path.join(tmpRoot, ".ade", "attachments");
fs.mkdirSync(attachDir, { recursive: true });
const stashedImage = path.join(attachDir, "stashed-image.png");
fs.writeFileSync(stashedImage, "image data");
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
fs.utimesSync(stashedImage, eightDaysAgo, eightDaysAgo);

const { service } = createService({
db: {
getJson: vi.fn(),
setJson: vi.fn(),
run: vi.fn(),
get: vi.fn().mockReturnValue({ site_id: "site-a" }),
all: vi.fn().mockReturnValue([{
attachments_json: JSON.stringify([{ path: stashedImage, type: "image" }]),
attachment_origin_site_id: "site-a",
}]),
},
});

service.cleanupStaleAttachments();

expect(fs.existsSync(stashedImage)).toBe(true);
});
});

// --------------------------------------------------------------------------
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
} from "@anthropic-ai/claude-agent-sdk";
import { z, type ZodType } from "zod";
import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message";
import { listPromptStashAttachmentPaths } from "./promptStashService";
import { ClaudeInputPump } from "./claudeInputPump";
import {
createClaudeStructuredActivityState,
Expand Down Expand Up @@ -6576,7 +6577,10 @@ export function createAgentChatService(args: {
sessionService: ReturnType<typeof createSessionService>;
processRegistry?: ProcessRegistryService | null;
projectConfigService: ReturnType<typeof createProjectConfigService>;
db?: Pick<AdeDb, "getJson" | "setJson"> | null;
db?: (
Pick<AdeDb, "getJson" | "setJson">
& Partial<Pick<AdeDb, "get" | "all" | "run" | "sync">>
) | null;
aiIntegrationService: ReturnType<typeof createAiIntegrationService>;
logger: Logger;
appVersion: string;
Expand Down Expand Up @@ -42313,12 +42317,22 @@ export function createAgentChatService(args: {
try {
const projectRoot = args.projectRoot;
if (!projectRoot) return;
const promptStashDb = args.db;
const protectedAttachmentPaths = promptStashDb
&& typeof promptStashDb.get === "function"
&& typeof promptStashDb.all === "function"
&& typeof promptStashDb.run === "function"
? new Set(Array.from(listPromptStashAttachmentPaths(
promptStashDb as Pick<AdeDb, "get" | "all" | "run"> & Partial<Pick<AdeDb, "sync">>,
), (filePath) => path.resolve(filePath)))
: new Set<string>();
const cleanupDir = (dirPath: string) => {
if (!fs.existsSync(dirPath)) return;
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
for (const entry of fs.readdirSync(dirPath)) {
try {
const filePath = path.join(dirPath, entry);
if (protectedAttachmentPaths.has(path.resolve(filePath))) continue;
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.rmSync(filePath, { recursive: true, force: true });
Expand Down
167 changes: 166 additions & 1 deletion apps/desktop/src/main/services/chat/promptStashService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { openKvDb, type AdeDb } from "../state/kvDb";
import {
createPromptStash,
deletePromptStash,
listPromptStashAttachmentPaths,
listPromptStashes,
MAX_PROMPT_STASHES,
MAX_PROMPT_STASH_ATTACHMENTS,
MAX_PROMPT_STASH_TEXT_CHARS,
} from "./promptStashService";

Expand All @@ -20,6 +22,33 @@ function createLogger() {
} as const;
}

function insertSyncedPromptStash(
db: AdeDb,
entry: {
id: string;
createdAt: string;
attachmentPath?: string;
},
): void {
db.run(
`
insert into prompt_stashes(
id, text, attachments_json, attachment_origin_site_id, provider, model_id, created_at
)
values (?, ?, ?, ?, null, null, ?)
`,
[
entry.id,
entry.id,
JSON.stringify(entry.attachmentPath
? [{ path: entry.attachmentPath, type: "image" }]
: []),
entry.attachmentPath ? db.sync.getSiteId() : null,
entry.createdAt,
],
);
}

describe("promptStashService", () => {
let root: string;
let db: AdeDb;
Expand Down Expand Up @@ -50,11 +79,100 @@ describe("promptStashService", () => {
expect(listPromptStashes(db)).toEqual([created]);
});

it("rejects empty and excessively large prompts", () => {
it("persists runtime-owned attachments for connected desktops", () => {
const attachments = [
{ path: "/project/.ade/attachments/design.png", type: "image" as const },
{ path: "https://example.com/reference.png", type: "image-url" as const, url: "https://example.com/reference.png" },
];

const created = createPromptStash(db, { text: "", attachments });

expect(created.attachments).toEqual(attachments);
expect(listPromptStashes(db)).toEqual([created]);
expect(listPromptStashAttachmentPaths(db)).toEqual(new Set([attachments[0]!.path]));
});

it("does not expose or consume machine-bound image paths on another synced runtime", () => {
const image = { path: "/source/.ade/attachments/design.png", type: "image" as const };
const created = createPromptStash(db, { text: "Use this design", attachments: [image] });
db.run(
"update prompt_stashes set attachment_origin_site_id = ? where id = ?",
["different-runtime", created.id],
);

expect(listPromptStashes(db)).toEqual([
expect.objectContaining({
id: created.id,
attachments: [],
attachmentCount: 1,
attachmentsAvailable: false,
}),
]);
expect(listPromptStashAttachmentPaths(db)).toEqual(new Set());
});

it("normalizes site ids before deciding whether machine-bound images are local", () => {
const image = { path: "/source/.ade/attachments/design.png", type: "image" as const };
const created = createPromptStash(db, { text: "Use this design", attachments: [image] });
db.run(
"update prompt_stashes set attachment_origin_site_id = ? where id = ?",
[` \n${db.sync.getSiteId().toUpperCase()}\t `, created.id],
);

expect(listPromptStashes(db)).toEqual([
expect.objectContaining({
id: created.id,
attachments: [image],
attachmentCount: 1,
attachmentsAvailable: true,
}),
]);
expect(listPromptStashAttachmentPaths(db)).toEqual(new Set([image.path]));
});

it("keeps portable image URLs while withholding cross-site image paths", () => {
const localImage = { path: "/source/.ade/attachments/design.png", type: "image" as const };
const portableImage = {
path: "https://example.com/reference.png",
type: "image-url" as const,
url: "https://example.com/reference.png",
};
const created = createPromptStash(db, {
text: "Compare these designs",
attachments: [localImage, portableImage],
});
db.run(
"update prompt_stashes set attachment_origin_site_id = ? where id = ?",
["different-runtime", created.id],
);

expect(listPromptStashes(db)).toEqual([
expect.objectContaining({
id: created.id,
attachments: [portableImage],
attachmentCount: 2,
attachmentsAvailable: false,
}),
]);
expect(listPromptStashAttachmentPaths(db)).toEqual(new Set());
});

it("rejects empty, excessively large, and malformed stashes", () => {
expect(() => createPromptStash(db, { text: " \n\t " })).toThrow("cannot be empty");
expect(() => createPromptStash(db, {
text: "x".repeat(MAX_PROMPT_STASH_TEXT_CHARS + 1),
})).toThrow("too large");
expect(() => createPromptStash(db, {
text: "bad attachment",
attachments: [{ path: "javascript:alert(1)", type: "image-url", url: "javascript:alert(1)" }],
})).toThrow("image URL is invalid");
expect(() => createPromptStash(db, {
text: "too many",
attachments: Array.from({ length: MAX_PROMPT_STASH_ATTACHMENTS + 1 }, (_, index) => ({
path: `/project/image-${index}.png`,
type: "image" as const,
})),
})).toThrow("at most");
expect(listPromptStashes(db)).toEqual([]);
});

Expand All @@ -69,6 +187,53 @@ describe("promptStashService", () => {
expect(listed.map((entry) => entry.id)).not.toContain(created[2]?.id);
});

it("prunes synchronized overflow before returning a bounded list", () => {
const olderTimestamp = "2026-07-28T12:00:00.000Z";
const newerTimestamp = "2026-07-28T12:00:01.000Z";
for (let index = 0; index < MAX_PROMPT_STASHES + 3; index += 1) {
insertSyncedPromptStash(db, {
id: `synced-${String(index).padStart(2, "0")}`,
createdAt: index === 0 ? olderTimestamp : newerTimestamp,
});
}

const listed = listPromptStashes(db, 5);
const retainedIds = db.all<{ id: string }>(
"select id from prompt_stashes order by created_at desc, id desc",
).map((row) => row.id);

expect(listed.map((entry) => entry.id)).toEqual(retainedIds.slice(0, 5));
expect(retainedIds).toHaveLength(MAX_PROMPT_STASHES);
expect(retainedIds).not.toContain("synced-00");
expect(retainedIds).not.toContain("synced-01");
expect(retainedIds).not.toContain("synced-02");
});

it("prunes synchronized overflow before protecting live attachment paths", () => {
const inserted = Array.from(
{ length: MAX_PROMPT_STASHES + 3 },
(_, index) => ({
id: `synced-image-${String(index).padStart(2, "0")}`,
createdAt: new Date(Date.parse("2026-07-28T12:00:00.000Z") + index).toISOString(),
attachmentPath: path.join(root, ".ade", "attachments", `image-${index}.png`),
}),
);
inserted.forEach((entry) => insertSyncedPromptStash(db, entry));

const protectedPaths = listPromptStashAttachmentPaths(db);
const retainedIds = db.all<{ id: string }>(
"select id from prompt_stashes order by created_at desc, id desc",
).map((row) => row.id);

expect(retainedIds).toHaveLength(MAX_PROMPT_STASHES);
expect(protectedPaths).toEqual(new Set(
inserted.slice(-MAX_PROMPT_STASHES).map((entry) => entry.attachmentPath),
));
expect(protectedPaths.has(inserted[0]!.attachmentPath)).toBe(false);
expect(protectedPaths.has(inserted[1]!.attachmentPath)).toBe(false);
expect(protectedPaths.has(inserted[2]!.attachmentPath)).toBe(false);
});

it("deletes atomically and reports already-consumed stashes", () => {
const created = createPromptStash(db, { text: "restore me" });

Expand Down
Loading