Skip to content
113 changes: 113 additions & 0 deletions packages/app/e2e/settings/settings-memory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test, expect } from "../fixtures"
import { closeSettingsPanel, openSettings, withSession } from "../actions"

test("@smoke memory settings exposes the raw MEMORY.md controls", async ({ page, project }) => {
await project.open()
await project.sdk.memory.reset()

const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Memory" }).click()

await expect(settings.getByRole("heading", { level: 2, name: "Memory" })).toBeVisible()
await expect(settings.getByText("Enable memory")).toBeVisible()
await expect(settings.getByText("Raw MEMORY.md", { exact: true })).toBeVisible()

const raw = settings.locator('[data-action="settings-memory-raw"]')
await expect(raw).toBeVisible()
await expect(raw).toHaveValue(/# PawWork Memory/)
await expect(raw).toHaveValue(/## Profile/)
await expect(raw).toHaveValue(/## Archive/)

const next = [
"# PawWork Memory",
"",
"## Profile",
"- e2e memory settings profile",
"",
"## Archive",
"",
].join("\n")

await raw.fill(next)
await settings.getByRole("button", { name: "Save" }).click()

await expect
.poll(async () => {
const state = await project.sdk.memory.get().then((response) => response.data as { content?: string })
return state.content ?? ""
})
.toContain("e2e memory settings profile")
})

test("idle session shows memory review and saves redacted entries", async ({ page, project }) => {
await project.open()
await project.sdk.memory.reset()
await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })

await withSession(project.sdk, "memory review e2e", async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)

const review = page.locator('[data-component="session-memory-review"]')
await expect(review).toBeVisible()
await expect(review.getByText("Remember this session?")).toBeVisible()

await review.locator('[data-action="session-memory-review-text"]').fill("Remember sk-test-secret from this session")
await review.getByRole("button", { name: "Save memory" }).click()

await expect(review).toBeHidden()
await expect
.poll(async () => {
const state = await project.sdk.memory.get().then((response) => response.data as { content?: string })
return state.content ?? ""
})
.toContain("[REDACTED]")

const state = await project.sdk.memory.get().then((response) => response.data as { content?: string })
expect(state.content ?? "").not.toContain("sk-test-secret")
})
})

test("disabled memory hides the idle session review surface", async ({ page, project }) => {
await project.open()
await project.sdk.memory.reset()
await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: true } })
try {
await project.sdk.memory.acceptProposal({ memoryProposalInput: { text: "Should not be saved.", scope: "project" } })
throw new Error("expected disabled memory proposal to be blocked")
} catch (error) {
expect((error as { error?: string }).error).toBe("memory_disabled")
}

try {
await withSession(project.sdk, "disabled memory review e2e", async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)

await expect(page.locator('[data-component="session-memory-review"]')).toBeHidden()
})
} finally {
await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })
}
})

test("turning memory off hides an already visible session review surface", async ({ page, project }) => {
await project.open()
await project.sdk.memory.reset()
await project.sdk.memory.disabled({ memoryDisabledInput: { disabled: false } })

await withSession(project.sdk, "memory review toggle e2e", async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)

const review = page.locator('[data-component="session-memory-review"]')
await expect(review).toBeVisible()

const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Memory" }).click()
await settings.locator('[data-slot="switch-control"]').click()
await closeSettingsPanel(page, settings)

await expect(review).toBeHidden()
})
})
9 changes: 9 additions & 0 deletions packages/app/src/components/memory/memory-state-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createSignal } from "solid-js"

const [memoryStateVersion, setMemoryStateVersion] = createSignal(0)

export { memoryStateVersion }

export function notifyMemoryStateChanged() {
setMemoryStateVersion((version) => version + 1)
}
136 changes: 136 additions & 0 deletions packages/app/src/components/memory/session-memory-review.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createResource, createSignal, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { memoryStateVersion } from "./memory-state-sync"

type MemoryState = {
disabled?: boolean
status?: "ok" | "safe_mode"
}

type MemoryError = {
error?: string
reason?: string
}

const errorMessage = (error: unknown, fallback: string) => (error instanceof Error ? error.message : fallback)

const memoryError = (value: unknown): MemoryError | undefined => {
if (!value || typeof value !== "object") return undefined
if ("error" in value || "reason" in value) return value as MemoryError
if ("data" in value) return memoryError((value as { data?: unknown }).data)
return undefined
}

export function SessionMemoryReview(props: { sessionID?: string; visible: boolean }) {
const language = useLanguage()
const sdk = useSDK()
const [draft, setDraft] = createSignal("")
const [dismissed, setDismissed] = createSignal(false)
const [saving, setSaving] = createSignal(false)
const [reviewState] = createResource(
() => (props.visible && props.sessionID ? { sessionID: props.sessionID, version: memoryStateVersion() } : undefined),
async () => {
const result = await sdk.client.memory.reviewState()
return (result.data ?? {}) as MemoryState
},
)

const show = () => props.visible && !dismissed() && reviewState.latest?.disabled === false && reviewState.latest?.status === "ok"

createEffect(() => {
props.sessionID
setDraft("")
setDismissed(false)
})

const accept = async () => {
const text = draft().trim()
if (!text || saving()) return
setSaving(true)
try {
const result = await sdk.client.memory.acceptProposal({ memoryProposalInput: { text, scope: "project" } })
const blocked = memoryError(result.error)
if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") {
setDraft("")
setDismissed(true)
showToast({
variant: "subtle",
title: language.t("memory.review.blocked"),
description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
})
return
}
const state = (result.data ?? {}) as MemoryState
if (state.disabled || state.status === "safe_mode") {
setDraft("")
setDismissed(true)
showToast({
variant: "subtle",
title: language.t("memory.review.blocked"),
description: language.t(state.disabled ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
})
return
}
setDraft("")
setDismissed(true)
showToast({
variant: "success",
title: language.t("memory.review.saved"),
description: language.t("memory.review.savedDescription"),
})
} catch (error) {
const blocked = memoryError(error)
if (blocked?.error === "memory_disabled" || blocked?.error === "memory_safe_mode") {
setDraft("")
setDismissed(true)
showToast({
variant: "subtle",
title: language.t("memory.review.blocked"),
description: language.t(blocked.error === "memory_disabled" ? "memory.review.blockedDisabled" : "memory.review.blockedSafeMode"),
})
return
}
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: errorMessage(error, language.t("common.requestFailed")),
})
} finally {
setSaving(false)
}
}

return (
<Show when={show()}>
<section
data-component="session-memory-review"
class="mx-3 mb-2 rounded-lg border border-border bg-bg-panel p-3 shadow-sm"
>
<div class="mb-2 text-13-medium text-fg-strong">{language.t("memory.review.title")}</div>
<textarea
data-action="session-memory-review-text"
class="min-h-[72px] w-full rounded border border-border bg-bg-base p-2 text-13-regular text-fg-strong"
value={draft()}
placeholder={language.t("memory.review.placeholder")}
onInput={(event) => setDraft(event.currentTarget.value)}
/>
<div class="mt-2 flex justify-end gap-2">
<Button
size="small"
onClick={() => {
setDismissed(true)
}}
>
{language.t("memory.review.dismiss")}
</Button>
<Button size="small" variant="primary" onClick={accept} disabled={!draft().trim() || saving()}>
{language.t("memory.review.accept")}
</Button>
</div>
</section>
</Show>
)
}
20 changes: 20 additions & 0 deletions packages/app/src/components/settings-memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"

describe("memory settings source contract", () => {
test("registers the Memory settings tab", () => {
const page = readFileSync("src/components/settings-page.tsx", "utf8")

expect(page).toContain('"memory"')
expect(page).toContain("SettingsMemory")
expect(page).toContain("settings.tab.memory")
})

test("keeps the v1 raw controls", () => {
const settings = readFileSync("src/components/settings-memory.tsx", "utf8")

expect(settings).toContain("settings-memory-raw")
expect(settings).toContain("memory.disabled")
expect(settings).not.toContain("settings-memory-delete-id")
})
})
Loading
Loading