feat(agent): extend chat context picker#988
Conversation
✅ Tests passed — 1236/1240
|
Greptile SummaryThis PR extends the BrowserOS chat
Confidence Score: 3/5The core prompt formatting and sanitisation are solid, but a live-filtering bug in memory ID construction will cause visually selected items to silently deselect during typing in the @ picker. Memory entry IDs embed a positional index that shifts whenever Fuse re-ranks results as the user types, causing selected items to appear unselected mid-interaction. The double-indexing of memory content (sections + individual lines) also produces overlapping results that would confuse users. Both issues live on the newly added context routes that are central to the feature. context.ts (memory ID stability, double indexing), agents.ts (missing title/source length caps), use-available-context.ts (unused items export) Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User at-picker
participant H as useAvailableContext
participant S as Server /context
participant CP as ChatFooter
participant BR as buildChatRequestBody
participant FS as format-message
participant LLM as LLM/ACP agent
U->>H: open picker, type filter
H->>S: "GET /context/files?cwd=X&q=filter"
H->>S: "GET /context/memories?q=filter"
S-->>H: files and memories arrays
H-->>U: render Files + Memories groups
U->>CP: toggle item, attachedContexts updated
U->>CP: submit message
CP->>BR: buildChatRequestBody with contextAttachments
BR-->>CP: strip id and truncated fields
CP->>S: POST /chat or /agents/id/sidepanel/chat
S->>FS: formatUserMessage with contextAttachments
FS-->>S: attached_context blocks prepended outside USER_QUERY
S->>LLM: formatted prompt with context
|
| const sections = content.split(/^## /m).filter(Boolean) | ||
| for (const section of sections) { | ||
| entries.push({ source: file, content: `## ${section}`.trim() }) | ||
| } | ||
|
|
||
| for (const line of content.split('\n')) { | ||
| const trimmed = line.trim() | ||
| if (trimmed && !trimmed.startsWith('#')) { | ||
| entries.push({ source: file, content: trimmed }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Double-indexing memory content creates duplicate/overlapping search results
loadMemoryEntries adds every memory file's content twice: once per H2 section (full section block) and again once per individual non-heading line. For a file with 3 sections containing 10 lines each, this produces 3 + 30 = 33 entries. Because section content contains the same lines as the line-level entries, Fuse can return both ## Section\nLine A\nLine B and Line A as top hits for the same query, showing confusing duplicates to the user. Consider choosing one granularity (section-level is usually more useful) or explicitly separating the two strategies.
| const sections = content.split(/^## /m).filter(Boolean) | |
| for (const section of sections) { | |
| entries.push({ source: file, content: `## ${section}`.trim() }) | |
| } | |
| for (const line of content.split('\n')) { | |
| const trimmed = line.trim() | |
| if (trimmed && !trimmed.startsWith('#')) { | |
| entries.push({ source: file, content: trimmed }) | |
| } | |
| } | |
| const sections = content.split(/^## /m).filter(Boolean) | |
| for (const section of sections) { | |
| entries.push({ source: file, content: `## ${section}`.trim() }) | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/server/src/api/routes/context.ts
Line: 149-159
Comment:
**Double-indexing memory content creates duplicate/overlapping search results**
`loadMemoryEntries` adds every memory file's content twice: once per H2 section (full section block) and again once per individual non-heading line. For a file with 3 sections containing 10 lines each, this produces 3 + 30 = 33 entries. Because section content contains the same lines as the line-level entries, Fuse can return both `## Section\nLine A\nLine B` and `Line A` as top hits for the same query, showing confusing duplicates to the user. Consider choosing one granularity (section-level is usually more useful) or explicitly separating the two strategies.
```suggestion
const sections = content.split(/^## /m).filter(Boolean)
for (const section of sections) {
entries.push({ source: file, content: `## ${section}`.trim() })
}
```
How can I resolve this? If you propose a fix, please make it concise.| : { error: 'Invalid selectedTextSource' } | ||
| } | ||
|
|
||
| function parseContextAttachments( | ||
| value: unknown, | ||
| ): { value?: UserContextAttachment[] } | { error: string } { | ||
| if (value === undefined) return { value: undefined } | ||
| if (!Array.isArray(value) || value.length > 10) { | ||
| return { error: 'Invalid contextAttachments' } | ||
| } | ||
|
|
||
| const attachments: UserContextAttachment[] = [] | ||
| for (const item of value) { | ||
| if (!item || typeof item !== 'object' || Array.isArray(item)) { | ||
| return { error: 'Invalid contextAttachments' } | ||
| } | ||
| const record = item as Record<string, unknown> | ||
| const kind = record.kind | ||
| const title = readOptionalTrimmedString(record, 'title') | ||
| const source = readOptionalTrimmedString(record, 'source') | ||
| const content = readOptionalString(record, 'content') | ||
| if ( | ||
| (kind !== 'file' && kind !== 'memory') || | ||
| !title || | ||
| !content || | ||
| content.length > 50_000 | ||
| ) { | ||
| return { error: 'Invalid contextAttachments' } | ||
| } | ||
| attachments.push({ kind, title, source, content }) | ||
| } | ||
|
|
||
| return { value: attachments } | ||
| } | ||
|
|
||
| function readOptionalString( | ||
| record: Record<string, unknown>, | ||
| key: string, |
There was a problem hiding this comment.
Missing title and source length caps in manual
contextAttachments parser
The Zod ContextAttachmentSchema in types.ts validates title.max(500) and source.max(1000), but the hand-written parseContextAttachments function here only checks that title is non-empty and content is ≤ 50,000 bytes — title and source are unbounded. A caller can send an arbitrarily long title or source string that passes validation and is then injected into prompt attributes via sanitizeAttribute.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/server/src/api/routes/agents.ts
Line: 883-920
Comment:
**Missing title and source length caps in manual `contextAttachments` parser**
The Zod `ContextAttachmentSchema` in `types.ts` validates `title.max(500)` and `source.max(1000)`, but the hand-written `parseContextAttachments` function here only checks that `title` is non-empty and `content` is ≤ 50,000 bytes — `title` and `source` are unbounded. A caller can send an arbitrarily long title or source string that passes validation and is then injected into prompt attributes via `sanitizeAttribute`.
How can I resolve this? If you propose a fix, please make it concise.|
Refinery rejected this merge request after Greptile reported a branch-caused P1: memory IDs use ranked result positions, so live @ picker filtering can change IDs and break selected memory state. Source issue bosmain-d4u has been reopened with details for rework. CI otherwise passed after rerunning an unrelated server-tools flake. |
Summary
Fixes #951
Test plan
git diff --check origin/dev...HEADbun --env-file=apps/server/.env.development test apps/agent/entrypoints/sidepanel/index/useChatSession.test.ts apps/server/tests/agent/format-message.test.ts apps/server/tests/api/services/chat-service.test.tsbunx biome checkon touched filesbun run typecheckfrompackages/browseros-agent/apps/server(blocked locally by existing tsc resolution issue tracked in bosmain-woi)bun run typecheckfrompackages/browseros-agent/apps/agent(blocked locally by existing wxt script resolution issue tracked in bosmain-090)bun run buildfrompackages/browseros-agent/apps/agent(blocked locally by existing graphql-codegen script resolution issue tracked in bosmain-4xe)