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: 7 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ jobs:

# PR #189 — port macOS du compositor (Metal + VideoToolbox).
#
# Compile check on a `macos-14` runner (Apple Silicon). Verifies that the
# Compile check on a supported `macos-latest` runner (Apple Silicon). Verifies that the
# scaffold + engine-layer code added by #189 actually compiles for
# `aarch64-apple-darwin` — bindgen + cc on macOS produce the ffmpeg FFI
# bindings + the VideoToolbox context that the Rust crate statically
Expand Down Expand Up @@ -133,15 +133,17 @@ jobs:
# * `shaders.metal` is compiled AT RUNTIME by `new_library_with_source`, so a
# whole file of invalid MSL (HLSL's global `cbuffer`/`Texture2D` have no MSL
# equivalent) type-checks perfectly — `every_shader_entry_point_compiles`
# catches it. macos-14 runners have a real Metal device, so it runs for real.
# catches it. Apple Silicon runners have a real Metal device, so it runs for real.
# * `AVERROR(EAGAIN)` is -11 on Windows/Linux and -35 on macOS. A hardcoded -11
# compiles anywhere and silently decodes zero frames —
# `averror_constants_match_the_ffmpeg_macros` confronts it with shim.c.
# * pipeline-state creation rejects shader/attachment mismatches only at
# `newRenderPipelineState` time — `the_compositor_builds_on_the_system_device`.
rust-macos-compositor-check:
name: Rust test (macOS compositor)
runs-on: macos-14
# Homebrew no longer bottles ffmpeg dependencies for macOS 14. Its source
# build of x265 fails in libtool before our Rust tests even start.
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- name: Install Rust toolchain
Expand All @@ -151,7 +153,7 @@ jobs:
- name: Install ffmpeg (homebrew-core) and verify headers/libs
run: |
brew update
# macos-14 images can retain an orphaned openssl@1.1 symlink.
# Older runner images can retain an orphaned openssl@1.1 symlink.
# `brew unlink` removes zero links there, so remove this one stale
# symlink only when it still points at 1.1 before installing ffmpeg.
if [[ -L /opt/homebrew/bin/openssl && "$(readlink /opt/homebrew/bin/openssl)" == *openssl@1.1* ]]; then
Expand Down Expand Up @@ -311,8 +313,7 @@ jobs:
# real display, a real tap and a real speaker — is `npm run test:sck-audio-timeline:mac`,
# which cannot run here and is run by hand.
#
# `macos-latest`, matching `build.yml`'s arm64 leg — NOT `macos-14` like the compositor job
# above, and the difference is load bearing. A gate has to compile with the toolchain that
# `macos-latest`, matching `build.yml`'s arm64 leg. A gate has to compile with the toolchain that
# actually ships the binary, or it fails on code that is fine and passes on code that is not.
# This job was written as `macos-14` by copying the job above and failed immediately:
# `ScreenCaptureRecorder.swift` has carried a trailing comma in an argument list since long
Expand Down
11 changes: 11 additions & 0 deletions FORK-CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ see [FORK.md](FORK.md).
For the dated review of newer upstream changes, see
[FORK-UPSTREAM-REVIEW.md](FORK-UPSTREAM-REVIEW.md).

## Unreleased — AI conversation persistence

- Saves each project's AI conversations, messages, generated-image references,
manual compaction state and rewind checkpoints under the local Studio profile.
Reopening the app restores the transcript and selects the newest conversation.
- Writes a temporary file and atomically replaces the prior history after every
change. An unreadable history file is left untouched instead of being silently
replaced with an empty one. Deleting a project also removes its saved chats.
- Older builds stored conversations only in memory, so chats already lost on
restart before this change cannot be recovered.

## Unreleased — ChatGPT subscription image scenes

- Adds an explicit image-scene action to the AI session whenever ChatGPT
Expand Down
38 changes: 37 additions & 1 deletion electron/ai-edition/chat-service.compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
// turn itself (so we can read the history it was given) and the chat model
// behind the summarizer, so no test here needs a provider or a key.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("./deep-agent/service", () => ({
invokeOpenScreenAgent: vi.fn(),
Expand All @@ -16,6 +19,7 @@ vi.mock("./deep-agent/chat-model", () => ({

import {
compactSessionNow,
configureChatSessionStorage,
createSession,
getSessionContextUsage,
runChat,
Expand Down Expand Up @@ -238,3 +242,35 @@ describe("compaction", () => {
});
});
});

describe("persisted compaction", () => {
let userDataPath: string;

beforeEach(async () => {
userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-compact-"));
configureChatSessionStorage(userDataPath);
});

afterEach(async () => {
configureChatSessionStorage(null);
await rm(userDataPath, { recursive: true, force: true });
});

it("keeps the compacted model boundary while restoring the full transcript", async () => {
stubSummarizer("EARLIER CONTEXT");
const projectId = "proj_compact_restart";
const session = createSession(projectId);
for (let index = 0; index < 4; index += 1) {
await runChat(projectId, session.id, "Prompt " + index + " ".repeat(300), stubConfig());
}
const compacted = await compactSessionNow(projectId, session.id, stubConfig());
expect(compacted).not.toBeNull();

configureChatSessionStorage(userDataPath);
expect(selectSession(projectId, session.id)?.messages).toHaveLength(8);
await runChat(projectId, session.id, "Next turn", stubConfig());
const modelHistory = histories.at(-1) ?? [];
expect(modelHistory[0]?.content).toBe("EARLIER CONTEXT");
expect(modelHistory.some((message) => message.content.startsWith("Prompt 0 "))).toBe(false);
});
});
91 changes: 90 additions & 1 deletion electron/ai-edition/chat-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { type AxcutDocument, createEmptyDocument } from "../../src/lib/ai-edition/schema";
import {
appendGeneratedScene,
configureChatSessionStorage,
createSession,
deleteProjectSessions,
deleteSession,
listSessions,
renameSession,
Expand Down Expand Up @@ -233,6 +236,19 @@ describe("runTimelineOperation", () => {
expect(session?.messages[0].role).toBe("assistant");
});

it("records the summary when a timeline operation creates its first chat", async () => {
const { documents } = makeDocumentsStub();
const result = await runTimelineOperation(
"proj_run_new",
"missing-session",
{ type: "add_trim_range", startSec: 5, endSec: 8 },
"Trimmed silence",
documents,
);
expect(result.success).toBe(true);
expect(listSessions("proj_run_new")).toEqual([expect.objectContaining({ messageCount: 1 })]);
});

it("returns success:false on getProject failure", async () => {
const documents = new BrokenDocumentService({ stored: makeDocument() });
const s = createSession("proj_run_err");
Expand All @@ -248,3 +264,76 @@ describe("runTimelineOperation", () => {
expect(result.error).toBe("disk is dead");
});
});

describe("chat session persistence", () => {
let userDataPath: string;

beforeEach(async () => {
userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-sessions-"));
configureChatSessionStorage(userDataPath);
});

afterEach(async () => {
configureChatSessionStorage(null);
await rm(userDataPath, { recursive: true, force: true });
});

it("restores renamed conversations and generated-image messages after a restart", () => {
const projectId = "proj_saved_chat";
const session = createSession(projectId);
renameSession(projectId, session.id, "Storyboard");
const generated = appendGeneratedScene(
projectId,
session.id,
"A violet sphere",
"C:/studio/source.png",
);
const other = createSession("proj_other_chat");

configureChatSessionStorage(userDataPath);

expect(listSessions(projectId)).toEqual([
expect.objectContaining({ id: session.id, title: "Storyboard", messageCount: 2 }),
]);
expect(selectSession(projectId, session.id)?.messages).toEqual([
generated.userMessage,
generated.assistantMessage,
]);
expect(selectSession(projectId, session.id)?.messages[1]?.generatedImagePath).toBe(
"C:/studio/source.png",
);
expect(listSessions("proj_other_chat")[0]?.id).toBe(other.id);
});

it("keeps deletion durable and removes all chats when their project is deleted", async () => {
const projectId = "proj_delete_chat";
const first = createSession(projectId);
const second = createSession(projectId);
expect(deleteSession(projectId, first.id)).toBe(true);

configureChatSessionStorage(userDataPath);
expect(listSessions(projectId).map((session) => session.id)).toEqual([second.id]);

deleteProjectSessions(projectId);
configureChatSessionStorage(userDataPath);
expect(listSessions(projectId)).toEqual([]);
await expect(
readFile(path.join(userDataPath, "chat-sessions", projectId + ".json"), "utf8"),
).rejects.toMatchObject({ code: "ENOENT" });
});

it("does not replace a corrupt history file with an empty conversation", async () => {
const projectId = "proj_corrupt_chat";
const file = path.join(userDataPath, "chat-sessions", projectId + ".json");
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, "{unfinished", "utf8");

expect(() => listSessions(projectId)).toThrow("Cannot load saved conversations");
expect(() => createSession(projectId)).toThrow("Cannot load saved conversations");
expect(await readFile(file, "utf8")).toBe("{unfinished");
});

it("rejects a project id that could escape the chat directory", () => {
expect(() => createSession("../outside")).toThrow("Invalid chat project id");
});
});
47 changes: 45 additions & 2 deletions electron/ai-edition/chat-service.toolloop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
// tests mock `invokeOpenScreenAgent` directly — that's the seam chat-service
// crosses to drive the agentic turn.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type AxcutDocument,
createEmptyDocument,
Expand All @@ -15,7 +18,13 @@ vi.mock("./deep-agent/service", () => ({
invokeOpenScreenAgent: vi.fn(),
}));

import { createSession, rewindToMessage, runChat } from "./chat-service";
import {
configureChatSessionStorage,
createSession,
rewindToMessage,
runChat,
selectSession,
} from "./chat-service";
import { invokeOpenScreenAgent } from "./deep-agent/service";
import type { LlmConfigStore } from "./llm-config-store";

Expand Down Expand Up @@ -324,6 +333,40 @@ describe("runChat tool loop", () => {
});
});

describe("persisted chat turn", () => {
let userDataPath: string;

beforeEach(async () => {
userDataPath = await mkdtemp(path.join(tmpdir(), "openscreen-chat-turn-"));
configureChatSessionStorage(userDataPath);
});

afterEach(async () => {
configureChatSessionStorage(null);
await rm(userDataPath, { recursive: true, force: true });
});

it("restores the transcript and its rewind checkpoint after a restart", async () => {
const projectId = "proj_restart_turn";
const session = createSession(projectId);
const document = createEmptyDocument({ projectId, title: "Test" });
const result = await runChat(projectId, session.id, "Trim the opening", stubConfig(), document);
expect(result.success).toBe(true);
const before = selectSession(projectId, session.id)?.messages;
expect(before?.map((message) => message.role)).toEqual(["user", "assistant"]);

configureChatSessionStorage(userDataPath);
expect(selectSession(projectId, session.id)?.messages).toEqual(before);
const userMessageId = before?.[0]?.id;
expect(userMessageId).toBeDefined();
const rewound = rewindToMessage(projectId, session.id, userMessageId!);
expect(rewound.success).toBe(true);

configureChatSessionStorage(userDataPath);
expect(selectSession(projectId, session.id)?.messages).toHaveLength(1);
});
});

// ── D-CONSENT ───────────────────────────────────────────────────────────────
//
// `allowAgentEdits` is offered in Settings as "Project edits — when off, the
Expand Down
Loading
Loading