Files
fusion/packages/engine/src/__tests__/executor-memory-capture.test.ts
ischindl 8fcf4bdbaa feat: Stash memory backend — session capture, per-chat backfill, opt-in vector search (#3494)
## Summary

Adds the **Stash memory backend** (`memory.backendType=stash`) that
connects Fusion's agent memory to the
[Stash](https://github.com/Fergana-Labs/stash) product — *knowledge
bases for the agent era* ([product site:
joinstash.ai](https://joinstash.ai)). Fusion becomes a first-class Stash
client: complete chat sessions and finished tasks are captured into
Stash, memory is recalled during chat, and Stash sessions are kept in
sync with the dashboard (including deletes and archival).

**Product:** <https://github.com/Fergana-Labs/stash> ·
[joinstash.ai](https://joinstash.ai)

## What's included

### 1. Stash memory backend (RUFU-068 / RUFU-121)
- New `StashMemoryBackend` (`memory.backendType=stash`) with `stashUrl`
/ `stashApiKey` settings (global secrets-store `stash-api-key` +
per-project override).
- **Complete-chat-session capture** keyed by ChatSession id.
- Sessions are classified into **per-project folders** (get-or-create,
`external_key fusion-<projectId>`, 1h per-process cache) and
**soft-deleted with their chat** via `DELETE /api/chat/sessions/:id`.
- Per-conversation **memory-focus** read-time scoping (new
`0066_chat_session_memory_focus.sql` migration — sequence renumbered
0059→0060→0061→0065→0066 as origin/main claimed the lower numbers);
event metadata enriched with `project` / `project_name` / `chat_title`.
- Recall queries normalized to single-keyword / explicit-OR ASCII (≤100
chars); shared normalizer export reused by per-turn recall.

### 2. Per-task executor transcript capture (RUFU-122)
Finished or failed tasks upload their executor transcript
(`agent-log.jsonl`) to Stash as a task session.

### 3. Bulk archive Stash sync (RUFU-125)
Archived task-planner chats soft-delete their Stash sessions on bulk
archival (paged). The snapshot of doomed session ids is taken *before*
the local bulk delete, and the Stash sync runs fire-and-forget so a
Stash stall can never delay local archival.

### 4. Per-chat "Preserve to Stash" backfill (RUFU-136)
A per-chat action that backfills a chat's full history into Stash, with
client-side idempotency and a pre-check that skips already-uploaded
content (fail-closed, no duplicate upload on transport failure).
- **Session-folder naming fix:** the first project folder is now named
"Fusion — &lt;project name&gt;" instead of the bare "Fusion" fallback
(the backfill now resolves the central-registry project name,
best-effort, never blocking the upload).

### 5. Opt-in semantic (vector) recall (RUFU-126)
`stashVectorSearch` setting (default `false` — **zero behavior change
until enabled**). For multi-word queries the backend tries Stash's
semantic-search endpoint first, then falls back byte-identically to the
keyword path. Definitive 404/405/501/503 responses are negatively cached
per process. Requires a patched Stash server (new endpoint +
`sentence-transformers` + embedding backfill); unpatched servers are
transparently bypassed after the first 404.

## Safety
- **Opt-in / inert by default:** the default backend remains `qmd`; the
Stash backend is inert until `memoryBackendType=stash` + `stashUrl` are
set.
- All Stash I/O is **best-effort, fail-closed, and non-blocking** — a
Stash outage never blocks chat, task completion, or archival. No
run-audit content is emitted.

## Testing
- Backfill + delete-sync suites (20/20), Stash backend suite (68/68),
executor memory / session capture suites, `memory-focus-recalling`,
description-guard — all green.
- `tsc` clean across core / engine / dashboard.
- Live verification: bulk backfill of 21/24 chats completed; the
"Preserve to Stash" action is idempotent on re-run.

## Changesets
- `@runfusion/fusion` **minor** — Stash memory backend + capture
(RUFU-068/121), per-task transcript (RUFU-122), bulk archive sync
(RUFU-125), per-chat backfill (RUFU-136), opt-in vector search
(RUFU-126)
- `@runfusion/fusion` **patch** — backfill session-folder naming fix


## Rebase Note (2026-08-23)

Rebased onto `origin/main` `3f448f7292` (v0.77.0-beta.7). Conflicts
resolved additively:
- `packages/core/src/postgres/schema-applier.ts` + test — upstream's
0062-0065 migrations (task/subtask splitting removal, AI merge review
reconciliation, task repository scope, FN-149 review convergence)
unioned with this PR's `chat_sessions.memory_focus` migration, which is
**renumbered 0065 → 0066** (upstream's FN-149 shipped 0065 canonically
on origin/main); `SCHEMA_BASELINE_VERSION` advances to `0066`.
- `packages/dashboard/app/components/ChatView.tsx` — upstream's docked
chat sidebar resize handlers unioned with the RUFU-136 "Preserve to
Stash" backfill handler.
- New commit: `settings.memory.*` stash-backend i18n keys added to all 6
secondary locales (RUFU-121/122 parity fix; `pnpm i18n:status` no longer
reports any violation introduced by this PR).

**Deploy note (operator environments that already ran a pre-rebase build
of this PR):** the memory-focus SQL may already be in the schema under
ledger row `0065`. Remap that row to `0066` (`UPDATE
fusion_schema_migrations SET version = '0066' WHERE version = '0065';`)
*before* first boot of a 0066-ceiling binary — otherwise the fresh
upstream `0065_fn_149_review_convergence_stage.sql` would be skipped as
"already applied". Clean databases (no prior memory-focus row) need no
action.

**CI note — Lint (lifecycle-column census) is red on the merge base:**
`pnpm check:lifecycle-columns --strict` fails identically on pure
`origin/main` `3f448f7292` with
`packages/core/src/db/legacy-adoption.ts: 0 -> 3` (3 column guards in
the U9b legacy-adoption table without a baseline entry or
`DELIBERATE-LITERAL` marker). Verified by running the census on a clean
origin/main checkout — inherited from the base, not introduced by this
PR. Fix belongs upstream; tracked separately.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Stash memory integration with project configuration and optional
semantic search.
  * Added per-chat memory focus controls and a `/focus` command.
  * Added “Preserve to Stash” for uploading complete chat history.
  * Added automatic chat, task transcript, and completion-event capture.
* Added project-specific Stash session folders and archive/delete
synchronization.
* **Bug Fixes**
* Improved Stash folder naming and handling of missing branches during
no-commit tasks.
* **Documentation**
* Added setup, configuration, integration, vector-search, and
performance guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
2026-08-23 16:46:14 -07:00

667 lines
26 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, resetExecutorMocks } from "./executor-test-helpers.js";
import {
ChatSessionMemoryCapture,
chatMessageToMemoryCaptureEvent,
createStashChatMemoryCaptureSink,
resolveStashMemorySettings,
triggerTaskMemoryCapture,
CHAT_MESSAGE_ADDED,
CHAT_SESSION_UPDATED,
type ChatEventEmitter,
} from "../executor/memory-capture.js";
/**
* Partial mock: keep every real @fusion/core symbol except `captureMemory`, which becomes a
* deterministic spy so capture paths never attempt a real (or dangling-url) network write.
*/
const { mockCaptureMemory } = vi.hoisted(() => ({ mockCaptureMemory: vi.fn() }));
vi.mock("@fusion/core", async (importOriginal) => {
const mod = await importOriginal<Record<string, unknown>>();
return { ...mod, captureMemory: mockCaptureMemory };
});
/**
* FNXC:MemoryCapture 2026-08-13-18:05:
* RUFU-068 complete-chat-session capture engine tests. The deterministic seam is the
* `ChatSessionMemoryCapture` service (injectable fake emitter + sink — no network), the pure
* message→event mapping, and the stash-secret resolver, plus the executor's per-task
* `task_completion` capture routed through `signalTaskComplete`.
*/
describe("resolveStashMemorySettings (stash secret resolution)", () => {
it("reads the global stash-api-key when backend is stash and no override is set", async () => {
const store = {
getSecretsStore: async () => ({
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "global-secret" }),
}),
};
const resolved = await resolveStashMemorySettings(store as any, {
memoryEnabled: true,
memoryBackendType: "stash",
stashUrl: "http://127.0.0.1:3457",
});
expect(resolved?.stashApiKey).toBe("global-secret");
});
it("never reads secrets for a non-stash backend", async () => {
const revealSecret = vi.fn();
const store = { getSecretsStore: async () => ({ revealSecret }) };
const resolved = await resolveStashMemorySettings(store as any, {
memoryEnabled: true,
memoryBackendType: "qmd",
});
expect(resolved?.stashApiKey).toBeUndefined();
expect(revealSecret).not.toHaveBeenCalled();
});
it("respects a per-project stashApiKey override without touching the secrets store", async () => {
const revealSecret = vi.fn();
const store = { getSecretsStore: async () => ({ revealSecret }) };
const resolved = await resolveStashMemorySettings(store as any, {
memoryEnabled: true,
memoryBackendType: "stash",
stashApiKey: "override",
});
expect(resolved?.stashApiKey).toBe("override");
expect(revealSecret).not.toHaveBeenCalled();
});
it("degrades to an empty key instead of throwing when the secrets store is missing", async () => {
const resolved = await resolveStashMemorySettings({} as any, {
memoryEnabled: true,
memoryBackendType: "stash",
});
expect(resolved?.stashApiKey).toBeUndefined();
});
});
describe("chatMessageToMemoryCaptureEvent (message → event mapping)", () => {
it("maps a user message to a user_message event with agent_name 'fusion'", () => {
const evt = chatMessageToMemoryCaptureEvent({
role: "user",
content: "hello",
metadata: null,
});
expect(evt.event_type).toBe("user_message");
expect(evt.agent_name).toBe("fusion");
expect(evt.content).toBe("hello");
});
it("maps an assistant message to an assistant_message event", () => {
const evt = chatMessageToMemoryCaptureEvent({
role: "assistant",
content: "hi there",
metadata: { agent_name: "claude" },
});
expect(evt.event_type).toBe("assistant_message");
expect(evt.agent_name).toBe("claude");
});
it("maps a system message with tool metadata to a tool_use event carrying tool_name", () => {
const evt = chatMessageToMemoryCaptureEvent({
role: "system",
content: "ran a tool",
metadata: { tool_name: "fn_task_list", agentName: "agent-7" },
});
expect(evt.event_type).toBe("tool_use");
expect(evt.tool_name).toBe("fn_task_list");
expect(evt.agent_name).toBe("agent-7");
});
it("omits tool_name for a system message without tool metadata", () => {
const evt = chatMessageToMemoryCaptureEvent({ role: "system", content: "x", metadata: {} });
expect(evt.event_type).toBe("tool_use");
expect((evt as Record<string, unknown>).tool_name).toBeUndefined();
});
/*
FNXC:RUFU146CreatedAt 2026-08-21-13:35:
RUFU-146 review (PRRT_kwDOSA-8Y86a7RaB): the mapper MUST preserve the
message's creation time via `created_at` (the Stash storage field). The
previous `timestamp` wire field was ignored server-side, so every event
landed with the receive wall-clock; buffered uploads lost message order.
*/
it("maps createdAt to created_at (exact value, no receive-time substitution)", () => {
const createdAt = "2026-08-21T09:14:02.500Z";
const evt = chatMessageToMemoryCaptureEvent({
role: "user",
content: "preserved",
metadata: null,
createdAt,
});
expect(evt.created_at).toBe(createdAt);
expect((evt as Record<string, unknown>).timestamp).toBeUndefined();
});
it("falls back to a parseable RFC3339 created_at when createdAt is omitted", () => {
const evt = chatMessageToMemoryCaptureEvent({ role: "assistant", content: "fallback", metadata: null });
expect(typeof evt.created_at).toBe("string");
expect(Number.isNaN(Date.parse(evt.created_at))).toBe(false);
});
});
class FakeChatEmitter implements ChatEventEmitter {
handlers: Record<string, Array<(...args: any[]) => void>> = {};
on(event: string, handler: (...args: any[]) => void): unknown {
(this.handlers[event] ??= []).push(handler);
return this;
}
off(event: string, handler: (...args: any[]) => void): unknown {
this.handlers[event] = (this.handlers[event] ?? []).filter((h) => h !== handler);
return this;
}
emit(event: string, payload: unknown): void {
for (const handler of this.handlers[event] ?? []) handler(payload);
}
}
function makeMessage(overrides: Record<string, unknown> = {}) {
return {
id: "m1",
sessionId: "ses-1",
role: "user",
content: "hello",
thinkingOutput: null,
metadata: null,
createdAt: new Date().toISOString(),
...overrides,
} as any;
}
describe("ChatSessionMemoryCapture (deterministic chat capture service)", () => {
beforeEach(() => {
mockCaptureMemory.mockReset();
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
});
function okSink() {
return vi.fn(async ({ events }: { events: unknown[] }) => ({
ok: true,
inserted: events.length,
deduped: 0,
}));
}
it("progressively emits one per-message event with session_id on chat:message:added", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({ sink: sink as any, rootDir: "/proj" });
const emitter = new FakeChatEmitter();
service.attach(emitter);
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", role: "user", content: "hi" }));
await new Promise((resolve) => setImmediate(resolve));
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m2", role: "assistant", content: "yo" }));
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(2);
const first = sink.mock.calls[0][0];
expect(first.sessionId).toBe("ses-1");
expect(first.rootDir).toBe("/proj");
expect(first.events).toHaveLength(1);
expect(first.events[0].event_type).toBe("user_message");
expect(first.events[0].content).toBe("hi");
const second = sink.mock.calls[1][0];
expect(second.events[0].event_type).toBe("assistant_message");
expect(service.pendingCount("ses-1")).toBe(0);
expect(service.dispatchedCount("ses-1")).toBe(2);
});
it("flushes buffered messages on conversation close (session archived)", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({
sink: sink as any,
rootDir: "/proj",
emitOnAdd: false,
});
const emitter = new FakeChatEmitter();
service.attach(emitter);
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", content: "a" }));
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m2", role: "assistant", content: "b" }));
await new Promise((resolve) => setImmediate(resolve));
expect(sink).not.toHaveBeenCalled();
expect(service.pendingCount("ses-1")).toBe(2);
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "archived" });
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
const call = sink.mock.calls[0][0];
expect(call.sessionId).toBe("ses-1");
expect(call.events).toHaveLength(2);
expect(call.events.map((e: any) => e.event_type)).toEqual(["user_message", "assistant_message"]);
expect(service.pendingCount("ses-1")).toBe(0);
});
it("does not re-emit an already-appended message when the session closes", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({ sink: sink as any, rootDir: "/proj" });
const emitter = new FakeChatEmitter();
service.attach(emitter);
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1" }));
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
// Conversation close with nothing left buffered must not duplicate the earlier append.
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "archived" });
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
});
it("never throws and keeps messages buffered for retry when the sink fails", async () => {
const sink = vi.fn(async () => {
throw new Error("backend down");
}) as any;
const service = new ChatSessionMemoryCapture({ sink, rootDir: "/proj" });
const emitter = new FakeChatEmitter();
service.attach(emitter);
expect(() => emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1" }))).not.toThrow();
await new Promise((resolve) => setImmediate(resolve));
expect(service.pendingCount("ses-1")).toBe(1);
});
/*
FNXC:RUFU121SessionIdentity 2026-08-18-19:53:
RUFU-121 Step 4: session project-identity stamping — the per-session cache is sourced from
chat:session:updated (the ONLY message-path source of a session's projectId/title); the
runtime identity is the fallback for sessions with no projectId of their own; a message
before any updated event degrades to no identity without throwing; the runtime project
name is NEVER stamped onto a cross-project session.
*/
it("stamps the session's project identity onto the sink when chat:session:updated carries it", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({ sink: sink as any, rootDir: "/proj", emitOnAdd: false });
const emitter = new FakeChatEmitter();
service.attach(emitter);
// Identity arrives on the session update BEFORE the first message flush.
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "active", projectId: "proj_x", title: "T" });
await new Promise((resolve) => setImmediate(resolve));
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", content: "a" }));
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "archived", projectId: "proj_x", title: "T" });
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
const call = sink.mock.calls[0][0];
expect(call.sessionId).toBe("ses-1");
expect(call.projectId).toBe("proj_x");
expect(call.chatTitle).toBe("T");
expect(call.projectName ?? null).toBeNull(); // no runtime identity → no name
});
it("falls back to the runtime project identity when the session carries no projectId", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({
sink: sink as any,
rootDir: "/proj",
emitOnAdd: false,
projectIdentity: { projectId: "proj-rt", projectName: "Runtime Project" },
});
const emitter = new FakeChatEmitter();
service.attach(emitter);
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "active", projectId: null, title: null });
await new Promise((resolve) => setImmediate(resolve));
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", content: "a" }));
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "archived", projectId: null, title: null });
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
const call = sink.mock.calls[0][0];
expect(call.projectId).toBe("proj-rt");
expect(call.projectName).toBe("Runtime Project"); // same project id → runtime name travels
expect(call.chatTitle ?? null).toBeNull();
});
it("does not stamp the runtime project name onto a cross-project session", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({
sink: sink as any,
rootDir: "/proj",
emitOnAdd: false,
projectIdentity: { projectId: "proj-rt", projectName: "Runtime Project" },
});
const emitter = new FakeChatEmitter();
service.attach(emitter);
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "active", projectId: "proj-other", title: "Other" });
await new Promise((resolve) => setImmediate(resolve));
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", content: "a" }));
emitter.emit(CHAT_SESSION_UPDATED, { id: "ses-1", status: "archived", projectId: "proj-other", title: "Other" });
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
const call = sink.mock.calls[0][0];
expect(call.projectId).toBe("proj-other"); // session identity wins over the runtime fallback
expect(call.projectName ?? null).toBeNull(); // name belongs to proj-rt, not proj-other
expect(call.chatTitle).toBe("Other");
});
it("omits identity without throwing when a message arrives before any session update", async () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({ sink: sink as any, rootDir: "/proj" });
const emitter = new FakeChatEmitter();
service.attach(emitter);
expect(() => emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", content: "a" }))).not.toThrow();
await new Promise((resolve) => setImmediate(resolve));
expect(sink).toHaveBeenCalledTimes(1);
const call = sink.mock.calls[0][0];
expect(call.projectId ?? null).toBeNull();
expect(call.projectName ?? null).toBeNull();
expect(call.chatTitle ?? null).toBeNull();
});
it("is idempotent across multiple attaches and detaches cleanly", () => {
const sink = okSink();
const service = new ChatSessionMemoryCapture({ sink: sink as any, rootDir: "/proj" });
const emitter = new FakeChatEmitter();
const detach = service.attach(emitter);
const detach2 = service.attach(emitter);
expect(detach).toBe(detach2);
detach();
expect(service.pendingCount("ses-1")).toBe(0);
});
});
describe("triggerTaskMemoryCapture (per-task completion capture)", () => {
beforeEach(() => {
mockCaptureMemory.mockReset();
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
});
it("captures a task_completion event for a stash backend using the resolved secret", async () => {
const captured = new Set<string>();
await triggerTaskMemoryCapture(
{
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
getSecretsStore: async () => ({
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
}),
} as any,
capturedMemoryTaskIds: captured,
rootDir: "/proj",
},
{ id: "FN-9", title: "Ship feature", status: "done" } as any,
);
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const [rootDir, settings, sessionId, events, meta] = mockCaptureMemory.mock.calls[0];
expect(rootDir).toBe("/proj");
expect(settings).toMatchObject({ memoryBackendType: "stash", stashApiKey: "k" });
expect(sessionId).toBe("fusion-task-FN-9");
expect(events[0]).toMatchObject({ event_type: "task_completion", content: "Ship feature" });
expect(meta).toMatchObject({ taskId: "FN-9" });
expect(captured.has("FN-9")).toBe(true);
});
it("is completion-gated: captures at most once per task", async () => {
const captured = new Set<string>();
const deps = {
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
} as any,
capturedMemoryTaskIds: captured,
rootDir: "/proj",
};
await triggerTaskMemoryCapture(deps, { id: "FN-9", title: "t", status: "done" } as any);
await triggerTaskMemoryCapture(deps, { id: "FN-9", title: "t", status: "done" } as any);
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
});
it("does nothing when memory capture is disabled", async () => {
await triggerTaskMemoryCapture(
{
store: { getSettings: async () => ({ memoryEnabled: false }) } as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
},
{ id: "FN-9", title: "t", status: "done" } as any,
);
expect(mockCaptureMemory).not.toHaveBeenCalled();
});
it("does nothing for a non-stash backend", async () => {
await triggerTaskMemoryCapture(
{
store: { getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "qmd" }) } as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
},
{ id: "FN-9", title: "t", status: "done" } as any,
);
expect(mockCaptureMemory).not.toHaveBeenCalled();
});
it("is non-blocking when secret resolution or capture throws", async () => {
mockCaptureMemory.mockRejectedValue(new Error("boom"));
await expect(
triggerTaskMemoryCapture(
{
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
} as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
},
{ id: "FN-9", title: "t", status: "done" } as any,
),
).resolves.toBeUndefined();
});
/*
FNXC:RUFU121TaskCaptureIdentity 2026-08-18-19:53:
RUFU-121 Step 4: the store-derived projectId is stamped into captureMemory meta so the Stash
backend attributes the capture to the per-project session folder (stable external_key
fusion-<projectId>). The project name travels ONLY when the caller explicitly provides it
(the folder resolves by external_key without a name); mock stores without getProjectId
degrade to no identity without throwing.
*/
it("stamps the store's projectId into captureMemory meta (store-derived fallback)", async () => {
await triggerTaskMemoryCapture(
{
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
getSecretsStore: async () => ({
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
}),
getProjectId: () => "proj-store",
} as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
},
{ id: "FN-42", title: "T", status: "done" } as any,
);
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta).toMatchObject({ taskId: "FN-42", projectId: "proj-store" });
expect(meta.projectName).toBeUndefined();
});
it("stamps an explicit projectIdentity (including name) ahead of the store-derived fallback", async () => {
await triggerTaskMemoryCapture(
{
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
getProjectId: () => "proj-store",
} as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
projectIdentity: { projectId: "proj-explicit", projectName: "Explicit Name" },
},
{ id: "FN-42", title: "T", status: "done" } as any,
);
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta).toMatchObject({ taskId: "FN-42", projectId: "proj-explicit", projectName: "Explicit Name" });
});
it("degrades to no project identity when the store lacks getProjectId (no throw)", async () => {
await triggerTaskMemoryCapture(
{
store: {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash" }),
} as any,
capturedMemoryTaskIds: new Set<string>(),
rootDir: "/proj",
},
{ id: "FN-42", title: "T", status: "done" } as any,
);
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta.projectId).toBeUndefined();
expect(meta.projectName).toBeUndefined();
});
});
describe("TaskExecutor signalTaskComplete per-task memory capture", () => {
beforeEach(() => {
resetExecutorMocks();
mockCaptureMemory.mockReset();
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
});
function makeTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-7528",
description: "Test task",
column: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
assignedAgentId: "agent-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: "Test task",
status: "done",
...overrides,
} as any;
}
it("captures task memory once on completion for a stash backend and never blocks completion", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
memoryEnabled: true,
memoryBackendType: "stash",
});
const onComplete = vi.fn();
const executor = new TaskExecutor(store as any, "/tmp/test", { onComplete });
const task = makeTask();
(executor as any).signalTaskComplete(task);
(executor as any).signalTaskComplete(task); // second completion must not capture twice
await new Promise((resolve) => setImmediate(resolve));
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(2);
});
it("skips task memory capture when memory is disabled, still forwarding onComplete", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({ memoryEnabled: false });
const onComplete = vi.fn();
const executor = new TaskExecutor(store as any, "/tmp/test", { onComplete });
(executor as any).signalTaskComplete(makeTask());
await new Promise((resolve) => setImmediate(resolve));
expect(mockCaptureMemory).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledTimes(1);
});
it("attachChatMemoryCapture subscribes and detaches without throwing", () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({ memoryEnabled: true, memoryBackendType: "stash" });
const executor = new TaskExecutor(store as any, "/tmp/test", {});
const emitter = new FakeChatEmitter();
const detach = executor.attachChatMemoryCapture(emitter as any);
expect(typeof detach).toBe("function");
emitter.emit(CHAT_MESSAGE_ADDED, makeMessage({ id: "m1", sessionId: "ses-x" }));
expect(() => detach()).not.toThrow();
});
});
/*
FNXC:RUFU121StashSinkIdentity 2026-08-18-19:53:
RUFU-121 Step 4: the stash sink factory threads the effective project identity into captureMemory's
meta when the caller supplies it via the sink params (the ChatSessionMemoryCapture path), and
falls back to the factory-level identity when the params carry none. Existing per-flush
settings+secret resolution behavior is preserved.
*/
describe("createStashChatMemoryCaptureSink (RUFU-121 Step 4 identity threading)", () => {
beforeEach(() => {
mockCaptureMemory.mockReset();
mockCaptureMemory.mockResolvedValue({ ok: true, inserted: 1, deduped: 0 });
});
function stashStore() {
return {
getSettings: async () => ({ memoryEnabled: true, memoryBackendType: "stash", stashUrl: "http://127.0.0.1:3457" }),
getSecretsStore: async () => ({
revealSecret: async () => ({ key: "stash-api-key", plaintextValue: "k" }),
}),
} as any;
}
it("threads the caller-supplied identity into captureMemory meta", async () => {
const sink = createStashChatMemoryCaptureSink(stashStore());
await sink({
sessionId: "ses-1",
events: [chatMessageToMemoryCaptureEvent({ role: "user", content: "hi", metadata: null })],
rootDir: "/proj",
projectId: "proj_x",
projectName: "Proj X",
chatTitle: "T",
});
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta).toMatchObject({ projectRoot: "/proj", projectId: "proj_x", projectName: "Proj X", chatTitle: "T" });
});
it("falls back to the factory-level identity when the sink params carry none", async () => {
const sink = createStashChatMemoryCaptureSink(stashStore(), { projectId: "proj-rt", projectName: "Runtime" });
await sink({
sessionId: "ses-1",
events: [chatMessageToMemoryCaptureEvent({ role: "user", content: "hi", metadata: null })],
rootDir: "/proj",
});
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta).toMatchObject({ projectId: "proj-rt", projectName: "Runtime" });
});
it("omits all identity meta when neither params nor factory identity provide any", async () => {
const sink = createStashChatMemoryCaptureSink(stashStore());
await sink({
sessionId: "ses-1",
events: [chatMessageToMemoryCaptureEvent({ role: "user", content: "hi", metadata: null })],
rootDir: "/proj",
});
expect(mockCaptureMemory).toHaveBeenCalledTimes(1);
const meta = mockCaptureMemory.mock.calls[0][4];
expect(meta).toMatchObject({ projectRoot: "/proj" });
expect(meta.projectId).toBeUndefined();
expect(meta.projectName).toBeUndefined();
expect(meta.chatTitle).toBeUndefined();
});
});