fix(knowledge): scope pins to sessions
This commit is contained in:
@@ -5,12 +5,9 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getLinkedIssues } from '@/lib/linkedIssues';
|
||||
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
|
||||
@@ -50,7 +47,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
}, [directory, loadSkills]);
|
||||
|
||||
/**
|
||||
* What the project sends along with every message. Read from the server
|
||||
* What this session carries. Read from the server
|
||||
* rather than from the notes panel's store, because this must be right
|
||||
* whether or not that panel has ever been opened.
|
||||
*/
|
||||
@@ -58,41 +55,34 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
|
||||
);
|
||||
|
||||
// Re-read whenever the stores that own pins or memory change, not only when
|
||||
// the directory does. Unpinning is a write those stores make, and a panel
|
||||
// that keeps listing what was just unpinned tells the user it is still going
|
||||
// to the agent when it is not.
|
||||
// Re-read when source content or memory changes, not only when the session does.
|
||||
const contextEntries = useProjectContextStore((state) => state.entries);
|
||||
const memoryProject = useAgentMemoryStore((state) => state.project);
|
||||
const memoryGlobal = useAgentMemoryStore((state) => state.global);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(directory).then((summary) => {
|
||||
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
|
||||
if (!cancelled) setKnowledge(summary);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [directory, contextEntries, memoryProject, memoryGlobal]);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, directory, projects]);
|
||||
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
|
||||
|
||||
// Unpinning from here, like the pinned-messages section: a panel that says
|
||||
// what is attached should be able to detach it, or the user has to go find
|
||||
// the surface that can.
|
||||
const unpinNote = React.useCallback((noteId: string) => {
|
||||
if (projectRef) void setNotePinned(projectRef, noteId, false);
|
||||
}, [projectRef, setNotePinned]);
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
|
||||
});
|
||||
}, [directory, sessionId]);
|
||||
const unpinPlan = React.useCallback((planId: string) => {
|
||||
if (projectRef) void setPlanPinned(projectRef, planId, false);
|
||||
}, [projectRef, setPlanPinned]);
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
|
||||
});
|
||||
}, [directory, sessionId]);
|
||||
|
||||
const memoryCount = knowledge.memory.global + knowledge.memory.project;
|
||||
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
|
||||
@@ -131,9 +121,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
|
||||
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
||||
}
|
||||
// Pinned knowledge outranks the ambient counts in the summary: it is
|
||||
// something the user chose for this project, not something that happens to
|
||||
// be installed.
|
||||
// Pinned knowledge outranks ambient counts because the user chose it for this session.
|
||||
if (summaryParts.length === 0 && pinnedCount > 0) {
|
||||
summaryParts.push(pinnedCount === 1
|
||||
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
|
||||
@@ -182,8 +170,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Named individually: a count alone would not tell the user which note
|
||||
is riding along with every message they send. */}
|
||||
{/* Named individually: a count alone would not identify this session's context. */}
|
||||
{/* The pin is the control, exactly as in the pinned-messages section
|
||||
above: same icon, same placement, same behaviour. Two pins that look
|
||||
different in one panel would read as two different things. */}
|
||||
@@ -194,7 +181,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!sessionId || !directory}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -216,7 +203,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!sessionId || !directory}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -60,14 +60,13 @@ Leaving the section or the project closes it, so its editor never sits over a
|
||||
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||
still pass `onOpenPlan` and keep theirs.
|
||||
|
||||
## Pins are project state, not a message attachment
|
||||
## Pins belong to one session
|
||||
|
||||
Pinning a note or plan writes to the project, not to the session, so it holds
|
||||
across every session in that project until it is unpinned. The composer once
|
||||
carried a chip for it, from when pinned context was a one-shot attachment to the
|
||||
next message; standing state shown permanently above the input reads as
|
||||
something being attached to what you are typing, which it is not. What is
|
||||
attached, and the control to detach it, live in the work status panel instead.
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
session metadata. Other sessions in the project do not inherit it. A pin made
|
||||
while a new-session draft is open lives on that draft and transfers only to the
|
||||
session created by its first message. Work status lists and detaches the pins of
|
||||
the session it describes.
|
||||
|
||||
## Memory is not a fifth kind of note
|
||||
|
||||
@@ -211,10 +210,8 @@ matched the old project would silently hide everything in the new one.
|
||||
|
||||
## Pinned context
|
||||
|
||||
The pin toggle on a note or plan marks it as standing context for the agent.
|
||||
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
|
||||
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
|
||||
what is riding along.
|
||||
The pin toggle on a note or plan attaches it to the current session or draft.
|
||||
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
|
||||
*/
|
||||
const NoteRow: React.FC<{
|
||||
note: ProjectNote;
|
||||
pinned: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onSaveBody: (body: string) => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = React.useState(note.body);
|
||||
const lastSavedRef = React.useRef(note.body);
|
||||
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
|
||||
onClick={onTogglePinned}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={note.pinned
|
||||
title={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
{/* Filled means pinned, outline means "pin this" — the same
|
||||
language the work status panel uses. */}
|
||||
<Icon name={note.pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
|
||||
notes: ProjectNote[];
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
}> = ({ projectRef, notes, disabled, query }) => {
|
||||
pinnedNoteIds: ReadonlySet<string>;
|
||||
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const [composerText, setComposerText] = React.useState('');
|
||||
// One at a time on purpose: notes can run to 3000 characters each, and
|
||||
@@ -173,7 +176,6 @@ export const NotesSection: React.FC<{
|
||||
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
|
||||
const createNote = useProjectContextStore((state) => state.createNote);
|
||||
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
||||
|
||||
const visibleNotes = React.useMemo(() => {
|
||||
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (noteId: string, pinned: boolean) => {
|
||||
const ok = await setNotePinned(projectRef, noteId, pinned);
|
||||
const ok = await onTogglePinned(noteId, pinned);
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
},
|
||||
[projectRef, reportFailure, setNotePinned, t]
|
||||
[onTogglePinned, reportFailure, t]
|
||||
);
|
||||
|
||||
const handleSaveBody = React.useCallback(
|
||||
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
|
||||
<NoteRow
|
||||
key={note.id}
|
||||
note={note}
|
||||
pinned={pinnedNoteIds.has(note.id)}
|
||||
expanded={expandedNoteId === note.id}
|
||||
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
||||
onSaveBody={(body) => handleSaveBody(note.id, body)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
|
||||
query: string;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan }) => {
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||
const createPlan = useProjectContextStore((state) => state.createPlan);
|
||||
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (planId: string, pinned: boolean) => {
|
||||
const ok = await setPlanPinned(projectRef, planId, pinned);
|
||||
const ok = await onTogglePinned(planId, pinned);
|
||||
if (!ok) {
|
||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
||||
}
|
||||
},
|
||||
[projectRef, setPlanPinned, t]
|
||||
[onTogglePinned, projectRef, t]
|
||||
);
|
||||
|
||||
const visiblePlans = React.useMemo(() => {
|
||||
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
|
||||
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={plan.pinned}
|
||||
aria-label={plan.pinned
|
||||
aria-pressed={pinnedPlanIds.has(plan.id)}
|
||||
aria-label={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={plan.pinned
|
||||
title={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
|
||||
import { PlansSection } from './PlansSection';
|
||||
import { TodosSection } from './TodosSection';
|
||||
import { useProjectTodoSend } from './useProjectTodoSend';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
|
||||
@@ -85,6 +87,43 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const saveTodos = useProjectContextStore((state) => state.saveTodos);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (newSessionDraft.open) {
|
||||
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
|
||||
if (!cancelled) {
|
||||
setSessionPins({
|
||||
notes: summary.notes.map((note) => note.id),
|
||||
plans: summary.plans.map((plan) => plan.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
|
||||
|
||||
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
|
||||
if (newSessionDraft.open) {
|
||||
setDraftProjectContextPin(kind, id, pinned);
|
||||
return true;
|
||||
}
|
||||
if (!currentSessionId || !currentSessionDirectory) return false;
|
||||
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
|
||||
if (!next) return false;
|
||||
setSessionPins(next);
|
||||
return true;
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
|
||||
|
||||
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
|
||||
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
|
||||
|
||||
// The whole feature is one switch: with memory off there is nothing for the
|
||||
// agent to manage, so showing the user what is stored would be pointless.
|
||||
@@ -387,6 +426,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
notes={contextEntry.notes}
|
||||
disabled={isLoading}
|
||||
query={query}
|
||||
pinnedNoteIds={pinnedNoteIds}
|
||||
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
projectRef={projectRef}
|
||||
plans={contextEntry.plans}
|
||||
query={query}
|
||||
pinnedPlanIds={pinnedPlanIds}
|
||||
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
|
||||
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
||||
// desktop panel the plan opens here, in place of the list.
|
||||
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface SessionKnowledge {
|
||||
/** Empty when the session already carries what it needs. */
|
||||
@@ -86,14 +87,17 @@ const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: {
|
||||
/** What the session is carrying, for display. Never throws; shows nothing instead. */
|
||||
export const fetchSessionKnowledgeSummary = async (
|
||||
directory: string | null,
|
||||
sessionId?: string | null,
|
||||
): Promise<SessionKnowledgeSummary> => {
|
||||
if (!directory) {
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ directory });
|
||||
if (sessionId) params.set('sessionId', sessionId);
|
||||
const response = await runtimeFetch(
|
||||
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`,
|
||||
`/api/session-knowledge/summary?${params.toString()}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -112,3 +116,29 @@ export const fetchSessionKnowledgeSummary = async (
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
};
|
||||
|
||||
export type SessionProjectContextPins = { notes: string[]; plans: string[] };
|
||||
|
||||
const sessionProjectContextPinsResponseSchema = z.object({
|
||||
pins: z.object({ notes: z.array(z.string()), plans: z.array(z.string()) }),
|
||||
});
|
||||
|
||||
export const setSessionProjectContextPin = async (
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
kind: 'note' | 'plan',
|
||||
id: string,
|
||||
pinned: boolean,
|
||||
): Promise<SessionProjectContextPins | null> => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/session-knowledge/pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory, sessionId, kind, id, pinned }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return sessionProjectContextPinsResponseSchema.parse(await response.json()).pins;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -373,6 +373,27 @@ describe("issue 2039 draft auto-accept", () => {
|
||||
expect(permissionAutoAcceptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("transfers draft project context pins only to the session it creates", async () => {
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
projectContextPins: { notes: ["note-a"], plans: [] },
|
||||
})
|
||||
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
|
||||
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[0]?.metadata).toEqual({
|
||||
openchamber: {
|
||||
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
|
||||
},
|
||||
})
|
||||
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
|
||||
|
||||
useSessionUIStore.getState().openNewSessionDraft()
|
||||
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
|
||||
|
||||
expect(createSessionCalls[1]?.metadata).toBe(undefined)
|
||||
})
|
||||
|
||||
test("uses the server-authoritative directory after worktree session creation", async () => {
|
||||
createdSessionDirectory = "/canonical/worktree"
|
||||
useSessionUIStore.getState().openNewSessionDraft({
|
||||
|
||||
@@ -270,6 +270,7 @@ export type NewSessionDraftState = {
|
||||
initialPrompt?: string
|
||||
syntheticParts?: SyntheticContextPart[]
|
||||
targetFolderId?: string
|
||||
projectContextPins?: { notes: string[]; plans: string[] }
|
||||
}
|
||||
|
||||
export type ViewportAnchor = {
|
||||
@@ -319,6 +320,7 @@ export type SessionUIState = {
|
||||
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
|
||||
setDraftPreserveDirectoryOverride: (value: boolean) => void
|
||||
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
|
||||
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
|
||||
acknowledgeSessionAbort: (sessionId: string) => void
|
||||
clearAbortPrompt: () => void
|
||||
armAbortPrompt: (durationMs?: number) => number | null
|
||||
@@ -726,7 +728,15 @@ export async function materializeOpenDraftSession(selection: {
|
||||
|
||||
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
|
||||
|
||||
const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
|
||||
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const created = await store.createSession(
|
||||
draft.title,
|
||||
draftDirectoryOverride,
|
||||
draft.parentID ?? null,
|
||||
draftPins.notes.length > 0 || draftPins.plans.length > 0
|
||||
? { openchamber: { project_context_pins: draftPins } }
|
||||
: undefined,
|
||||
)
|
||||
if (!created?.id) throw new Error("Failed to create session")
|
||||
|
||||
// The server response is authoritative. It may canonicalize a requested
|
||||
@@ -1026,6 +1036,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
initialPrompt: options?.initialPrompt,
|
||||
syntheticParts: options?.syntheticParts,
|
||||
targetFolderId: options?.targetFolderId,
|
||||
projectContextPins: options?.projectContextPins,
|
||||
}
|
||||
|
||||
set({
|
||||
@@ -1138,6 +1149,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } }
|
||||
}),
|
||||
|
||||
setDraftProjectContextPin: (kind, id, pinned) =>
|
||||
set((s) => {
|
||||
if (!s.newSessionDraft?.open) return s
|
||||
const pins = s.newSessionDraft.projectContextPins ?? { notes: [], plans: [] }
|
||||
const key = kind === "note" ? "notes" : "plans"
|
||||
const next = new Set(pins[key])
|
||||
if (pinned) next.add(id)
|
||||
else next.delete(id)
|
||||
return {
|
||||
newSessionDraft: {
|
||||
...s.newSessionDraft,
|
||||
projectContextPins: { ...pins, [key]: [...next] },
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
acknowledgeSessionAbort: (sessionId) =>
|
||||
set((s) => {
|
||||
const flags = new Map(s.sessionAbortFlags)
|
||||
@@ -1578,14 +1605,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteSession — calls SDK, SSE event updates child store
|
||||
// ---------------------------------------------------------------------------
|
||||
deleteSession: async (id, options) => {
|
||||
const deleted = await deleteSessionAction(id, options)
|
||||
if (deleted) {
|
||||
// Nothing to forget here any more: what a session was told lives in its
|
||||
// own metadata and goes with it.
|
||||
}
|
||||
return deleted
|
||||
},
|
||||
deleteSession: async (id, options) => deleteSessionAction(id, options),
|
||||
|
||||
deleteSessions: async (ids, options) => {
|
||||
const result = await deleteSessionsAction(ids, options)
|
||||
|
||||
@@ -69,7 +69,13 @@ export const createContextObligatoryRuntime = ({
|
||||
*/
|
||||
const knowledge = sessionKnowledgeRuntime
|
||||
? await sessionKnowledgeRuntime
|
||||
.resolvePending(directory, sessionKnowledgeRuntime.readDeliveredSignature(session))
|
||||
.resolvePending(
|
||||
directory,
|
||||
// Compaction removed the previously delivered block, so its stored
|
||||
// signature is no longer evidence that the session still carries it.
|
||||
'',
|
||||
sessionKnowledgeRuntime.readPins(session),
|
||||
)
|
||||
.catch(() => ({ text: '', signature: '' }))
|
||||
: { text: '', signature: '' };
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ describe('context obligatory runtime', () => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.url);
|
||||
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body });
|
||||
if (url.pathname === '/session/ses_1' && init.method === 'PATCH') return json({});
|
||||
if (url.pathname === '/session/ses_1') return json({ id: 'ses_1', metadata: {} });
|
||||
if (url.pathname === '/session/ses_1') return json({
|
||||
id: 'ses_1',
|
||||
metadata: { openchamber: { knowledge_context_delivered: 'sig-before-compaction' } },
|
||||
});
|
||||
if (url.pathname === '/session/ses_1/message') return json([
|
||||
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
|
||||
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } },
|
||||
@@ -81,18 +84,30 @@ describe('context obligatory runtime', () => {
|
||||
if (url.pathname === '/session/ses_1/prompt_async') return json({});
|
||||
throw new Error(`Unexpected ${url.pathname}`);
|
||||
}));
|
||||
const resolvePending = vi.fn(async () => ({
|
||||
text: '## Pinned notes\n\n- Remember this.',
|
||||
signature: 'sig-1',
|
||||
}));
|
||||
const runtime = createContextObligatoryRuntime({
|
||||
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
sessionKnowledgeRuntime: {
|
||||
metadataKey: 'knowledge_context_delivered',
|
||||
readDeliveredSignature: () => '',
|
||||
resolvePending: async () => ({ text: '## Pinned notes\n\n- Remember this.', signature: 'sig-1' }),
|
||||
readPins: () => ({ notes: ['n1'], plans: [] }),
|
||||
resolvePending,
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.processPayload({ type: 'session.compacted', properties: { sessionID: 'ses_1' } });
|
||||
await runtime.processPayload({
|
||||
type: 'session.compacted',
|
||||
properties: { sessionID: 'ses_1', directory: '/work/project' },
|
||||
});
|
||||
|
||||
expect(resolvePending).toHaveBeenCalledWith(
|
||||
'/work/project',
|
||||
'',
|
||||
{ notes: ['n1'], plans: [] },
|
||||
);
|
||||
const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
|
||||
expect(JSON.parse(prompt.body).parts[0].text).toContain('Remember this.');
|
||||
const patch = requests.find((request) => request.method === 'PATCH');
|
||||
@@ -123,7 +138,7 @@ describe('context obligatory runtime', () => {
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
sessionKnowledgeRuntime: {
|
||||
metadataKey: 'knowledge_context_delivered',
|
||||
readDeliveredSignature: () => '',
|
||||
readPins: () => ({ notes: ['n1'], plans: [] }),
|
||||
resolvePending: async () => ({ text: 'Pinned notes block', signature: 'sig-1' }),
|
||||
},
|
||||
});
|
||||
@@ -152,7 +167,7 @@ describe('context obligatory runtime', () => {
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
sessionKnowledgeRuntime: {
|
||||
metadataKey: 'knowledge_context_delivered',
|
||||
readDeliveredSignature: () => 'sig-1',
|
||||
readPins: () => ({ notes: [], plans: [] }),
|
||||
resolvePending: async () => ({ text: '', signature: 'sig-1' }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,11 +27,10 @@ Nothing outside this module may write `context.json` or the `plans` directory.
|
||||
"notes": [{
|
||||
"id": "", "body": "", "createdAt": 0, "updatedAt": 0,
|
||||
"source": "manual | selection | agent",
|
||||
"pinned": false,
|
||||
"origin": { "sessionId": "", "messageId": "" }
|
||||
}],
|
||||
"todos": [{ "id": "", "text": "", "completed": false, "createdAt": 0 }],
|
||||
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0, "pinned": false }]
|
||||
"plans": [{ "id": "", "file": "1700000000-title.md", "title": "", "createdAt": 0 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -39,6 +38,8 @@ Notes are entries, not one blob. Version 1 stored a single string; it converts
|
||||
to a single `manual` note on read (an empty string converts to no notes at
|
||||
all). The conversion lives in the read path rather than a separate migration
|
||||
pass so that every reader — including one racing a writer — sees one shape.
|
||||
Legacy `pinned` fields may remain in existing files but are ignored; attachment
|
||||
ownership lives in each session's metadata.
|
||||
|
||||
`source` records where a note came from, and `origin` links it back to the
|
||||
message it was distilled from, so a note taken off a chat selection can be
|
||||
@@ -62,9 +63,9 @@ the two ever disagree.
|
||||
| GET | `/api/project-context/:projectId` | full context; missing file is `200` empty |
|
||||
| PUT | `/api/project-context/:projectId/todos` | replaces the whole list; returns committed context |
|
||||
| POST | `/api/project-context/:projectId/notes` | `201`; takes `{body, source?, origin?}` |
|
||||
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body` and/or `pinned`; `404` when unknown |
|
||||
| PATCH | `/api/project-context/:projectId/notes/:noteId` | patches `body`; legacy `pinned` input is ignored by session knowledge; `404` when unknown |
|
||||
| DELETE | `/api/project-context/:projectId/notes/:noteId` | `404` when unknown |
|
||||
| PATCH | `/api/project-context/:projectId/plans/:planId` | pin state only; `404` when unknown |
|
||||
| PATCH | `/api/project-context/:projectId/plans/:planId` | legacy project pin state only; session attachment uses session knowledge; `404` when unknown |
|
||||
| GET | `/api/project-context/:projectId/plans/:planId` | `404` when the link or its markdown is gone |
|
||||
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
|
||||
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Session Knowledge
|
||||
|
||||
What a session must be told about the project — the user's pinned notes and
|
||||
What a session must be told about the project — that session's pinned notes and
|
||||
plans, and the index of what the agent has remembered — and whether it has been
|
||||
told yet.
|
||||
|
||||
@@ -19,6 +19,11 @@ on believing it does and never sends it again.
|
||||
|
||||
## The contract
|
||||
|
||||
`session.metadata.openchamber.project_context_pins` owns the note and plan ids
|
||||
attached to that session. Pins never come from project-wide note or plan state.
|
||||
A new-session draft passes its pins into this metadata when its first message
|
||||
creates the session.
|
||||
|
||||
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
|
||||
of what the session is carrying. It lives with the session, so it survives the
|
||||
tab closing and is visible to every sender, including the ones with no tab.
|
||||
@@ -78,5 +83,5 @@ no session index, no settings row and no panel tab — absent rather than switch
|
||||
off, which would invite turning on something never announced. The setting itself
|
||||
also defaults to off, so setting the variable does not enable memory by itself.
|
||||
|
||||
Pinned notes and plans are unaffected: they ship as normal and travel with every
|
||||
message whether or not memory exists.
|
||||
Pinned notes and plans are unaffected by the memory switch and remain scoped to
|
||||
the session that pinned them.
|
||||
|
||||
@@ -55,14 +55,34 @@ export const registerSessionKnowledgeRoutes = (app, dependencies) => {
|
||||
if (!directory) {
|
||||
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
|
||||
}
|
||||
const sessionId = asNonEmptyString(req.query.sessionId);
|
||||
try {
|
||||
return res.json(await sessionKnowledgeRuntime.collectSummary(directory));
|
||||
return res.json(sessionId
|
||||
? await sessionKnowledgeRuntime.collectSummaryForSession(sessionId, directory)
|
||||
: await sessionKnowledgeRuntime.collectSummary(directory));
|
||||
} catch {
|
||||
// A panel that cannot read this shows nothing rather than an error.
|
||||
return res.json({ notes: [], plans: [], memory: { global: 0, project: 0 } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/session-knowledge/pin', parseJsonBody, async (req, res) => {
|
||||
const body = req.body;
|
||||
if (!isRecord(body)) return res.status(400).json({ error: 'Body must be an object' });
|
||||
const sessionId = asNonEmptyString(body.sessionId);
|
||||
const directory = asNonEmptyString(body.directory);
|
||||
const id = asNonEmptyString(body.id);
|
||||
const kind = body.kind === 'note' || body.kind === 'plan' ? body.kind : '';
|
||||
if (!sessionId || !directory || !id || !kind || typeof body.pinned !== 'boolean') {
|
||||
return res.status(400).json({ error: 'sessionId, directory, kind, id and pinned are required' });
|
||||
}
|
||||
try {
|
||||
return res.json({ pins: await sessionKnowledgeRuntime.setPin(sessionId, directory, kind, id, body.pinned) });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error?.message ?? 'Unable to update pin' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/session-knowledge/delivered', parseJsonBody, async (req, res) => {
|
||||
const body = req.body;
|
||||
if (!isRecord(body)) {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
const KNOWLEDGE_METADATA_KEY = 'knowledge_context_delivered';
|
||||
const PINS_METADATA_KEY = 'project_context_pins';
|
||||
|
||||
/** Total budget for the assembled block; anything past it is cut, loudly. */
|
||||
const KNOWLEDGE_MAX_LENGTH = 8000;
|
||||
@@ -119,7 +120,17 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
* source never blanks the rest: a memory store that will not load must not
|
||||
* take the user's pinned notes down with it.
|
||||
*/
|
||||
const collect = async (directory) => {
|
||||
const readPins = (session) => {
|
||||
const metadata = isRecord(session?.metadata) ? session.metadata : {};
|
||||
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const pins = isRecord(openchamber[PINS_METADATA_KEY]) ? openchamber[PINS_METADATA_KEY] : {};
|
||||
const strings = (value) => Array.isArray(value)
|
||||
? [...new Set(value.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim()))]
|
||||
: [];
|
||||
return { notes: strings(pins.notes), plans: strings(pins.plans) };
|
||||
};
|
||||
|
||||
const collect = async (directory, pins = { notes: [], plans: [] }) => {
|
||||
const projectId = directory ? await resolveProjectId(directory) : '';
|
||||
|
||||
let notes = [];
|
||||
@@ -127,8 +138,10 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
if (projectId) {
|
||||
try {
|
||||
const context = await projectContextRuntime.readContext(projectId);
|
||||
notes = (context.notes || []).filter((note) => note.pinned);
|
||||
const pinnedPlans = (context.plans || []).filter((plan) => plan.pinned);
|
||||
const noteIds = new Set(pins.notes);
|
||||
const planIds = new Set(pins.plans);
|
||||
notes = (context.notes || []).filter((note) => noteIds.has(note.id));
|
||||
const pinnedPlans = (context.plans || []).filter((plan) => planIds.has(plan.id));
|
||||
plans = await Promise.all(pinnedPlans.map(async (plan) => {
|
||||
try {
|
||||
const content = await projectContextRuntime.readPlan(projectId, plan.id);
|
||||
@@ -175,7 +188,7 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
* off disk to show a number would make opening a panel cost what sending a
|
||||
* message costs.
|
||||
*/
|
||||
const collectSummary = async (directory) => {
|
||||
const collectSummary = async (directory, pins = { notes: [], plans: [] }) => {
|
||||
const projectId = directory ? await resolveProjectId(directory) : '';
|
||||
const empty = { notes: [], plans: [], memory: { global: 0, project: 0 } };
|
||||
if (!projectId) return empty;
|
||||
@@ -184,9 +197,11 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
let plans = [];
|
||||
try {
|
||||
const context = await projectContextRuntime.readContext(projectId);
|
||||
notes = (context.notes || []).filter((note) => note.pinned)
|
||||
const noteIds = new Set(pins.notes);
|
||||
const planIds = new Set(pins.plans);
|
||||
notes = (context.notes || []).filter((note) => noteIds.has(note.id))
|
||||
.map((note) => ({ id: note.id, body: note.body }));
|
||||
plans = (context.plans || []).filter((plan) => plan.pinned)
|
||||
plans = (context.plans || []).filter((plan) => planIds.has(plan.id))
|
||||
.map((plan) => ({ id: plan.id, title: plan.title }));
|
||||
} catch {
|
||||
notes = [];
|
||||
@@ -223,8 +238,8 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
* The text this session still owes, or an empty string when it is already
|
||||
* carrying it. `deliveredSignature` comes from the session's metadata.
|
||||
*/
|
||||
const resolvePending = async (directory, deliveredSignature) => {
|
||||
const collected = await collect(directory);
|
||||
const resolvePending = async (directory, deliveredSignature, pins = { notes: [], plans: [] }) => {
|
||||
const collected = await collect(directory, pins);
|
||||
const signature = buildKnowledgeSignature(collected);
|
||||
if (!signature || signature === deliveredSignature) {
|
||||
return { text: '', signature };
|
||||
@@ -241,7 +256,38 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
*/
|
||||
const resolvePendingForSession = async (sessionId, directory) => {
|
||||
const session = await readSession(sessionId, directory).catch(() => null);
|
||||
return resolvePending(directory, readDeliveredSignature(session));
|
||||
return resolvePending(directory, readDeliveredSignature(session), readPins(session));
|
||||
};
|
||||
|
||||
const collectSummaryForSession = async (sessionId, directory) => {
|
||||
const session = await readSession(sessionId, directory).catch(() => null);
|
||||
return collectSummary(directory, readPins(session));
|
||||
};
|
||||
|
||||
const setPin = async (sessionId, directory, kind, id, pinned) => {
|
||||
const fresh = await readSession(sessionId, directory);
|
||||
const metadata = isRecord(fresh?.metadata) ? fresh.metadata : {};
|
||||
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
|
||||
const pins = readPins(fresh);
|
||||
const key = kind === 'note' ? 'notes' : 'plans';
|
||||
const next = new Set(pins[key]);
|
||||
if (pinned) next.add(id);
|
||||
else next.delete(id);
|
||||
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
|
||||
directory,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
metadata: {
|
||||
...metadata,
|
||||
openchamber: {
|
||||
...openchamber,
|
||||
[PINS_METADATA_KEY]: { ...pins, [key]: [...next] },
|
||||
[KNOWLEDGE_METADATA_KEY]: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return { ...pins, [key]: [...next] };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -272,10 +318,14 @@ export const createSessionKnowledgeRuntime = (dependencies) => {
|
||||
return {
|
||||
collect,
|
||||
collectSummary,
|
||||
collectSummaryForSession,
|
||||
resolvePending,
|
||||
resolvePendingForSession,
|
||||
recordDelivered,
|
||||
readDeliveredSignature,
|
||||
readPins,
|
||||
setPin,
|
||||
metadataKey: KNOWLEDGE_METADATA_KEY,
|
||||
pinsMetadataKey: PINS_METADATA_KEY,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildKnowledgeSignature, buildKnowledgeText, createSessionKnowledgeRunt
|
||||
|
||||
const DIRECTORY = '/work/project';
|
||||
const PROJECT_ID = 'path_project';
|
||||
const PINS = { notes: ['n1'], plans: ['p1'] };
|
||||
|
||||
const note = (overrides = {}) => ({
|
||||
id: 'n1', body: 'Pinned note body.', createdAt: 1, updatedAt: 1, pinned: true, source: 'manual', ...overrides,
|
||||
@@ -26,12 +27,13 @@ const createRuntime = (overrides = {}) => createSessionKnowledgeRuntime({
|
||||
readAll: async () => ({ global: [memory()], project: [], globalFailed: false, projectFailed: false }),
|
||||
...overrides.agentMemoryRuntime,
|
||||
},
|
||||
...('openCodeFetch' in overrides ? { openCodeFetch: overrides.openCodeFetch } : {}),
|
||||
...('isAgentMemoryEnabled' in overrides ? { isAgentMemoryEnabled: overrides.isAgentMemoryEnabled } : {}),
|
||||
});
|
||||
|
||||
describe('what the session is owed', () => {
|
||||
test('carries pinned notes, pinned plan bodies, and the memory index', async () => {
|
||||
const { text } = await createRuntime().resolvePending(DIRECTORY, '');
|
||||
const { text } = await createRuntime().resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).toContain('Pinned note body.');
|
||||
expect(text).toContain('Migration plan');
|
||||
@@ -52,7 +54,7 @@ describe('what the session is owed', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', { notes: [], plans: [] });
|
||||
|
||||
expect(text).not.toContain('Pinned note body.');
|
||||
});
|
||||
@@ -123,7 +125,7 @@ describe('when a source will not load', () => {
|
||||
agentMemoryRuntime: { readAll: async () => { throw new Error('unreadable'); } },
|
||||
});
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).toContain('Pinned note body.');
|
||||
});
|
||||
@@ -135,7 +137,7 @@ describe('when a source will not load', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).not.toContain('Uses bun');
|
||||
});
|
||||
@@ -148,7 +150,7 @@ describe('when a source will not load', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).toContain('Migration plan');
|
||||
expect(text).toContain('plan content unavailable');
|
||||
@@ -159,7 +161,7 @@ describe('when a source will not load', () => {
|
||||
projectContextRuntime: { readContext: async () => { throw new Error('unreadable'); } },
|
||||
});
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).toContain('Uses bun');
|
||||
});
|
||||
@@ -169,7 +171,7 @@ describe('the memory switch', () => {
|
||||
test('memory is left out entirely while the feature is off', async () => {
|
||||
const runtime = createRuntime({ isAgentMemoryEnabled: async () => false });
|
||||
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '');
|
||||
const { text } = await runtime.resolvePending(DIRECTORY, '', PINS);
|
||||
|
||||
expect(text).not.toContain('Uses bun');
|
||||
expect(text).toContain('Pinned note body.');
|
||||
@@ -187,6 +189,39 @@ describe('the memory switch', () => {
|
||||
});
|
||||
|
||||
describe('reading what a session was told', () => {
|
||||
test('project context pins are isolated in each session metadata record', () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
expect(runtime.readPins({
|
||||
metadata: { openchamber: { project_context_pins: { notes: ['n1'], plans: [] } } },
|
||||
})).toEqual({ notes: ['n1'], plans: [] });
|
||||
expect(runtime.readPins({
|
||||
metadata: { openchamber: { project_context_pins: { notes: [], plans: ['p1'] } } },
|
||||
})).toEqual({ notes: [], plans: ['p1'] });
|
||||
expect(runtime.readPins({})).toEqual({ notes: [], plans: [] });
|
||||
});
|
||||
|
||||
test('pinning updates only the target session and invalidates its delivered signature', async () => {
|
||||
const requests = [];
|
||||
const runtime = createRuntime({
|
||||
openCodeFetch: async (path, options = {}) => {
|
||||
requests.push({ path, options });
|
||||
if (options.method === 'PATCH') return {};
|
||||
return {
|
||||
metadata: { openchamber: { project_context_pins: { notes: [], plans: [] }, knowledge_context_delivered: 'old' } },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.setPin('ses_a', DIRECTORY, 'note', 'n1', true);
|
||||
|
||||
expect(requests.map((request) => request.path)).toEqual(['/session/ses_a', '/session/ses_a']);
|
||||
expect(requests[1].options.body.metadata.openchamber).toEqual({
|
||||
project_context_pins: { notes: ['n1'], plans: [] },
|
||||
knowledge_context_delivered: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('finds the signature stored on the session', () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user