Skip to content

Commit aaa9537

Browse files
simple-agent-manager[bot]raphaeltmclaude
authored
feat(chat): origin-tag & collapse SAM-injected prompt text (persist + broadcast) (#1569)
* feat(vm-agent): thread origin marker from injected prompt block to message reporter - startAgentWithPrompt emits SAM-injected instructions as a separate ACP block carrying _meta{sam.origin:system}; create-session body accepts injectedInstructions - ExtractMessages reads the in-memory marker and sets ExtractedMessage.Origin - Origin flows through MessageReportEntry -> outbox (additive origin column + idempotent migration) -> batch payload to the control plane - Go tests for marker extraction, outbox persist/forward, migration idempotency * feat(api,web): persist + collapse origin=system injected messages - producer: buildInjectedInstructions() splits the get_instructions reminder out of the visible prompt; startAgentSessionOnNode sends it as injectedInstructions - API: MessageEntrySchema.origin; toProjectDataMessages forwards it - DO: additive migration 024 (ALTER TABLE chat_messages ADD COLUMN origin, no DROP); persistMessageBatch INSERT + getMessages SELECT + row parsers carry origin - web: ChatMessageResponse.origin + UserMessage.origin; chatMessagesToConversationItems maps it; AcpConversationItemView collapses origin=system behind a chevron disclosure - tests: producer split, DO origin persist/read (workers), web collapse component; updated task-runner prompt tests for the split Persisted path only; live-broadcast hiding deferred (SDK strips _meta outbound). * feat: finish injected message origin propagation * test: audit injected context disclosure * test: account for origin migration * test: preserve prompt origin contracts * fix(origin): gate system-origin marker to trusted prompts; address review findings Specialist review fixes for the injected-message origin feature: - security: only honor _meta[sam.origin]=system from the SAM control-plane initial prompt; strip it from untrusted browser/gateway + follow-up prompts so a viewer cannot hide their own content from search/dedup/topic/attention. HandlePrompt gains trustedSource; regression test added. - ui: fix invisible focus ring (ring-accent-primary -> ring-focus-ring); harden Playwright audit (mount wait, boolean open, robust selector). - api: emit origin:null on the single-message message.new broadcast for a stable contract vs messages.batch. - tests: add dedup-bypass, conversion origin-mapping, DO topic-exclusion, and attention-exclusion regressions. - docs: CLAUDE.md Recent Changes + architecture overview.md; migration comment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove dead buildAgentStartPromptPayload task-completion-validator flagged the old combined-prompt helper as dead code (rule 01): no production caller remains after the visible/injected prompt split. Removed the function and its test; retest the visible-prompt helper asserts the reminder is NOT concatenated into the visible prompt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: drop unrelated prettier reflow from project-data/index + node-agent The origin commit reformatted the entire ProjectData DO index (799->1309 lines) and node-agent.ts as a side effect, which (a) tripped the 800-line file-size hard gate and (b) buried a 1-line functional change under ~490 lines of churn. The repo does not enforce prettier in CI (main ships the dense style), so this restores the status-quo formatting and keeps only the real origin changes: - index.ts: persistMessageBatch input type gains `origin?` - node-agent.ts: startAgentSessionOnNode gains `injectedInstructions?` forwarded to the vm-agent create-session body Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: re-trigger checks with corrected preflight classification Reclassified as cross-component (not external-api-change); no code change. * fix(origin): forward origin through project-data service to the DO Staging E2E revealed the injected origin=system message persisted with origin=null: the service-layer persistMessageBatch (project-data.ts) re-mapped batch messages before the DO RPC and dropped `origin` — a missing-propagation bug at a boundary no test covered (vm-agent tests, DO worker tests, and the un-run CI worker tests each mock only one side). Add origin to the param type and the re-map, plus a service-layer unit regression test (runs in CI, unlike the DO worker tests) asserting origin reaches the stub. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: post-mortem for origin service-boundary drop + CI worker-test gap backlog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Raphaël Titsworth-Morin <raphael@raphaeltm.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dcb9ca8 commit aaa9537

49 files changed

Lines changed: 2074 additions & 641 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ Tasks tracked as markdown in `tasks/` (backlog -> active -> archive). See `tasks
241241

242242
Use the `/changelog` skill for structured queries.
243243

244+
- origin-tag-injected-messages: SAM-injected prompt text (e.g. the `get_instructions` reminder) is no longer concatenated into the visible user prompt. It is passed separately as `injectedInstructions` on the vm-agent create-session HTTP body (`startAgentSessionOnNode` → `buildInitialPromptParams`), emitted as a second ACP prompt block tagged `_meta["sam.origin"]="system"`. The vm-agent honors that marker ONLY from a trusted control-plane prompt (`HandlePrompt(..., trustedSource)`) — browser viewer prompts have it stripped so a user cannot hide their own content. Origin propagates through the messagereport outbox (new `origin` column), the ProjectData DO `chat_messages` table (additive migration `024-chat-message-origin`, NULL/absent = `user`), `ChatMessageResponse`, and the live `session/update` broadcast. The web collapses `origin=system` user messages behind a `<details>` "Show system context" disclosure (`AcpConversationItemView`). `origin=system` content is excluded from user-content dedup, LIKE/FTS search (materialization filters it out of `chat_messages_grouped`), topic auto-capture, and attention resolution.
244245
- chat-full-load-timeline-jump: Project chat loads the FULL conversation on open (`getSessionMessageLimit` split into a small default page size `CHAT_SESSION_MESSAGE_LIMIT` for the 3s poll/load-more and a large `CHAT_SESSION_MESSAGE_MAX` ceiling the initial load requests; new `DEFAULT_CHAT_SESSION_MESSAGE_MAX`). The 30 MiB RPC size guard + `hasMore` "Load earlier" remain the fallback for oversized sessions. Timeline jump-to-message now anchors by messageId+timestamp with a `loadUntil` fallback (no more dead clicks), every entry kind (user message, status update, activity) is clickable, and the jumped-to message flashes (`.sam-message-highlight`). Removed the now-dead `messageIndex`/`messageIndexMap` from the timeline.
245246
- skills-profile-override-layer: Skills as a first-class profile-override layer (skill → profile → project → platform); additive migration `0063_skills.sql` adds `skills`, `skill_runtime_env_vars`, `skill_runtime_files` and `tasks.skill_id`/`tasks.skill_hint`/`triggers.skill_id`; shared mappers in `profile-fields.ts`/`profile-runtime-assets.ts`; CRUD + runtime routes under `/api/projects/:projectId/skills`; resolution wired through task/trigger/MCP/dispatch + workspace runtime asset injection; Skills UI + chat/task skill selectors in `apps/web`
246247
- explicit-sam-provider-selection: Require explicit opt-in to SAM as AI provider via `providerMode: 'sam'`; three-mode agent auth (user-api-key, oauth, sam); AI proxy auth gate on all endpoints including /models

apps/api/src/durable-objects/migrations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,15 @@ export const MIGRATIONS: Migration[] = [
636636
sql.exec(`CREATE INDEX IF NOT EXISTS idx_chat_sessions_created_by ON chat_sessions(created_by_user_id)`);
637637
},
638638
},
639+
{
640+
// Origin tag for SAM-injected messages (e.g. the get_instructions reminder)
641+
// so the UI can collapse them. Additive column; NULL/absent = normal user
642+
// message. No DROP/recreate (rule 31 — chat_messages is a CASCADE parent).
643+
name: '024-chat-message-origin',
644+
run: (sql) => {
645+
sql.exec(`ALTER TABLE chat_messages ADD COLUMN origin TEXT`);
646+
},
647+
},
639648
];
640649

641650
/**

apps/api/src/durable-objects/project-data/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export class ProjectData extends DurableObject<Env> {
115115

116116
async persistMessageBatch(
117117
sessionId: string,
118-
batchMessages: Array<{ messageId: string; role: string; content: string; toolMetadata: string | null; timestamp: string; sequence?: number }>
118+
batchMessages: Array<{ messageId: string; role: string; content: string; toolMetadata: string | null; timestamp: string; sequence?: number; origin?: string | null }>
119119
): Promise<messagePersistence.MessageBatchPersistenceResult> {
120120
return messagePersistence.persistMessageBatchWithSideEffects(this.sql, this.env, this.messagePersistenceHooks(), sessionId, batchMessages);
121121
}

apps/api/src/durable-objects/project-data/materialization.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,33 +32,24 @@ export function materializeSession(sql: SqlStorage, sessionId: string): void {
3232
if (!session) return;
3333
const { materializedAt } = parseMaterializationCheck(session);
3434
if (materializedAt !== null) return;
35-
3635
const tokens = sql
3736
.exec(
38-
'SELECT id, role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY created_at ASC, sequence ASC',
37+
"SELECT id, role, content, created_at FROM chat_messages WHERE session_id = ? AND COALESCE(origin, 'user') != 'system' ORDER BY created_at ASC, sequence ASC",
3938
sessionId
4039
)
4140
.toArray()
4241
.map((row) => parseMaterializationToken(row));
4342

4443
if (tokens.length === 0) {
45-
sql.exec(
46-
'UPDATE chat_sessions SET materialized_at = ? WHERE id = ?',
47-
Date.now(),
48-
sessionId
49-
);
44+
sql.exec('UPDATE chat_sessions SET materialized_at = ? WHERE id = ?', Date.now(), sessionId);
5045
return;
5146
}
5247

5348
// Group consecutive same-role tokens
5449
const grouped: Array<{ id: string; role: string; content: string; createdAt: number }> = [];
5550
for (const token of tokens) {
5651
const last = grouped[grouped.length - 1];
57-
if (
58-
last &&
59-
last.role === token.role &&
60-
GROUPABLE_ROLES.has(token.role)
61-
) {
52+
if (last && last.role === token.role && GROUPABLE_ROLES.has(token.role)) {
6253
last.content += token.content;
6354
} else {
6455
grouped.push({
@@ -97,11 +88,7 @@ export function materializeSession(sql: SqlStorage, sessionId: string): void {
9788
}
9889
}
9990

100-
sql.exec(
101-
'UPDATE chat_sessions SET materialized_at = ? WHERE id = ?',
102-
Date.now(),
103-
sessionId
104-
);
91+
sql.exec('UPDATE chat_sessions SET materialized_at = ? WHERE id = ?', Date.now(), sessionId);
10592
}
10693

10794
/**
@@ -135,7 +122,9 @@ export function materializeAllStopped(
135122
}
136123

137124
const remainingRow = sql
138-
.exec(`SELECT COUNT(*) as count FROM chat_sessions WHERE status = 'stopped' AND materialized_at IS NULL`)
125+
.exec(
126+
`SELECT COUNT(*) as count FROM chat_sessions WHERE status = 'stopped' AND materialized_at IS NULL`
127+
)
139128
.toArray()[0];
140129
const remaining = remainingRow ? parseCount(remainingRow, 'materialization.remaining') : 0;
141130

apps/api/src/durable-objects/project-data/message-persistence.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type BatchMessageInput = {
1515
toolMetadata: string | null;
1616
timestamp: string;
1717
sequence?: number;
18+
origin?: string | null;
1819
};
1920

2021
export type MessagePersistenceHooks = {
@@ -79,6 +80,11 @@ export async function persistMessageWithSideEffects(
7980
toolMetadata: parseToolMetadata(toolMetadata, sessionId),
8081
createdAt: result.now,
8182
sequence: result.sequence,
83+
// The single-message path only persists browser/RPC user messages, which
84+
// are never system-injected — origin is always null here. Emitting it
85+
// keeps the message.new payload shape aligned with messages.batch (which
86+
// carries origin) so live consumers see a stable contract.
87+
origin: null,
8288
},
8389
sessionId
8490
);
@@ -118,7 +124,7 @@ export async function persistMessageBatchWithSideEffects(
118124

119125
const latestMessageAt = result.persistedMessages.reduce(
120126
(latest, message) => Math.max(latest, message.createdAt),
121-
0,
127+
0
122128
);
123129
if (latestMessageAt > 0) {
124130
sessionState.refreshWorkingActivityForChatSession(sql, sessionId, latestMessageAt);
@@ -146,9 +152,9 @@ async function resolveAttentionForRoles(
146152
sql: SqlStorage,
147153
hooks: MessagePersistenceHooks,
148154
sessionId: string,
149-
persistedMessages: Array<{ id: string; role: string }>
155+
persistedMessages: Array<{ id: string; role: string; origin?: string | null }>
150156
): Promise<void> {
151-
const firstUserMsg = persistedMessages.find((m) => m.role === 'user');
157+
const firstUserMsg = persistedMessages.find((m) => m.role === 'user' && m.origin !== 'system');
152158
const firstAssistantMsg = persistedMessages.find((m) => m.role === 'assistant');
153159
let resolved = 0;
154160
let reason: string | null = null;

apps/api/src/durable-objects/project-data/messages.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ export function resolveCompactMessageOptions(env: Env): CompactMessageOptions {
4141
const parsed = Number.parseInt(env.DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES || '', 10);
4242
return {
4343
documentCardRawOutputMaxBytes:
44-
Number.isFinite(parsed) && parsed > 0
45-
? parsed
46-
: DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES,
44+
Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_DOCUMENT_CARD_RAW_OUTPUT_MAX_BYTES,
4745
};
4846
}
4947

@@ -150,6 +148,7 @@ export function persistMessageBatch(
150148
toolMetadata: string | null;
151149
timestamp: string;
152150
sequence?: number;
151+
origin?: string | null;
153152
}>
154153
): {
155154
persisted: number;
@@ -161,6 +160,7 @@ export function persistMessageBatch(
161160
toolMetadata: unknown;
162161
createdAt: number;
163162
sequence: number;
163+
origin: string | null;
164164
}>;
165165
workspaceId: string | null;
166166
firstUserContent: string | null;
@@ -195,13 +195,15 @@ export function persistMessageBatch(
195195
toolMetadata: unknown;
196196
createdAt: number;
197197
sequence: number;
198+
origin: string | null;
198199
}> = [];
199200

200201
// Track user message content seen within this batch to avoid redundant
201202
// DB queries when the same user content appears multiple times in one batch.
202203
const seenUserContent = new Set<string>();
203204

204205
for (const msg of messages) {
206+
const origin = msg.origin ?? null;
205207
const existing = sql
206208
.exec('SELECT id FROM chat_messages WHERE id = ?', msg.messageId)
207209
.toArray()[0];
@@ -221,7 +223,7 @@ export function persistMessageBatch(
221223
// persistMessageBatch (VM agent batch path) because the WebSocket handler
222224
// persists synchronously on receipt, while the batch arrives after the VM
223225
// agent processes the prompt, extracts messages, and flushes (~2-5s later).
224-
if (msg.role === 'user') {
226+
if (msg.role === 'user' && origin !== 'system') {
225227
if (seenUserContent.has(msg.content)) {
226228
duplicates++;
227229
continue;
@@ -250,15 +252,16 @@ export function persistMessageBatch(
250252
const createdAt = new Date(msg.timestamp).getTime() || now;
251253
const sequence = msg.sequence ?? nextSeq++;
252254
sql.exec(
253-
`INSERT INTO chat_messages (id, session_id, role, content, tool_metadata, created_at, sequence)
254-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
255+
`INSERT INTO chat_messages (id, session_id, role, content, tool_metadata, created_at, sequence, origin)
256+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
255257
msg.messageId,
256258
sessionId,
257259
msg.role,
258260
msg.content,
259261
msg.toolMetadata,
260262
createdAt,
261-
sequence
263+
sequence,
264+
origin
262265
);
263266
persisted++;
264267
persistedMessages.push({
@@ -268,6 +271,7 @@ export function persistMessageBatch(
268271
toolMetadata: msg.toolMetadata ? JSON.parse(msg.toolMetadata) : null,
269272
createdAt,
270273
sequence,
274+
origin,
271275
});
272276
}
273277

@@ -284,7 +288,9 @@ export function persistMessageBatch(
284288
);
285289

286290
if (!session.topic) {
287-
const firstUserMsg = messages.find((m) => m.role === 'user');
291+
const firstUserMsg = messages.find(
292+
(m) => m.role === 'user' && (m.origin ?? null) !== 'system'
293+
);
288294
if (firstUserMsg) {
289295
firstUserContent = firstUserMsg.content;
290296
const truncatedTopic =
@@ -351,7 +357,7 @@ export function getMessages(
351357
compactOptions?: CompactMessageOptions
352358
): { messages: Record<string, unknown>[]; hasMore: boolean } {
353359
let query =
354-
'SELECT id, session_id, role, content, tool_metadata, created_at, sequence FROM chat_messages WHERE session_id = ?';
360+
'SELECT id, session_id, role, content, tool_metadata, created_at, sequence, origin FROM chat_messages WHERE session_id = ?';
355361
const params: (string | number)[] = [sessionId];
356362

357363
if (before !== null) {
@@ -526,7 +532,10 @@ function searchMessagesLike(
526532
onlyNonMaterialized: boolean = false
527533
): SearchResult[] {
528534
const escapedQuery = query.replace(/[%_\\]/g, '\\$&');
529-
const conditions: string[] = ["m.content LIKE ? ESCAPE '\\'"];
535+
const conditions: string[] = [
536+
"m.content LIKE ? ESCAPE '\\'",
537+
"COALESCE(m.origin, 'user') != 'system'",
538+
];
530539
const params: (string | number)[] = [`%${escapedQuery}%`];
531540

532541
if (sessionId) {

apps/api/src/durable-objects/project-data/row-schemas/messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const ChatMessageRowSchema = v.object({
1616
tool_metadata: v.nullable(v.string()),
1717
created_at: v.number(),
1818
sequence: v.nullable(v.number()),
19+
// Added by migration 024-chat-message-origin; optional so pre-migration rows
20+
// and SELECTs that omit the column still parse (defaults to null → 'user').
21+
origin: v.optional(v.nullable(v.string())),
1922
});
2023

2124
export function parseChatMessageRow(row: unknown): {
@@ -26,6 +29,7 @@ export function parseChatMessageRow(row: unknown): {
2629
toolMetadata: unknown;
2730
createdAt: number;
2831
sequence: number | null;
32+
origin: string | null;
2933
} {
3034
const r = parseRow(ChatMessageRowSchema, row, 'chat_message');
3135
return {
@@ -36,6 +40,7 @@ export function parseChatMessageRow(row: unknown): {
3640
toolMetadata: safeParseJson(r.tool_metadata),
3741
createdAt: r.created_at,
3842
sequence: r.sequence,
43+
origin: r.origin ?? null,
3944
};
4045
}
4146

@@ -58,6 +63,7 @@ export function parseChatMessageRowCompact(row: unknown, options?: CompactMessag
5863
toolMetadata: unknown;
5964
createdAt: number;
6065
sequence: number | null;
66+
origin: string | null;
6167
} {
6268
const r = parseRow(ChatMessageRowSchema, row, 'chat_message');
6369
const parsed = safeParseJson(r.tool_metadata);
@@ -70,6 +76,7 @@ export function parseChatMessageRowCompact(row: unknown, options?: CompactMessag
7076
toolMetadata,
7177
createdAt: r.created_at,
7278
sequence: r.sequence,
79+
origin: r.origin ?? null,
7380
};
7481
}
7582

apps/api/src/durable-objects/task-runner/agent-session-step.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import { log } from '../../lib/logger';
88
import {
9-
buildAgentStartPromptPayload,
9+
buildSamBootstrapInstructions,
1010
buildVisibleInitialPrompt,
1111
} from '../../services/agent-bootstrap-prompt';
1212
import { transitionToInProgress } from './state-machine';
@@ -17,12 +17,11 @@ export function buildTaskAgentSessionLabel(taskTitle: string): string {
1717
}
1818

1919
export function buildTaskInitialPrompt(state: TaskRunnerState): string {
20-
return buildAgentStartPromptPayload({
21-
message: state.config.taskDescription || state.config.taskTitle,
22-
attachments: state.config.attachments,
23-
systemPromptAppend: state.config.systemPromptAppend,
24-
contextType: 'task',
25-
});
20+
return buildVisibleTaskInitialPrompt(state);
21+
}
22+
23+
export function buildInjectedInstructions(): string {
24+
return buildSamBootstrapInstructions({ contextType: 'task' });
2625
}
2726

2827
function buildVisibleTaskInitialPrompt(state: TaskRunnerState): string {
@@ -35,7 +34,7 @@ function buildVisibleTaskInitialPrompt(state: TaskRunnerState): string {
3534

3635
export async function handleAgentSession(
3736
state: TaskRunnerState,
38-
rc: TaskRunnerContext,
37+
rc: TaskRunnerContext
3938
): Promise<void> {
4039
await rc.updateD1ExecutionStep(state.taskId, 'agent_session');
4140

@@ -47,9 +46,9 @@ export async function handleAgentSession(
4746

4847
// Step 1: Create the agent session (skip if already created on a previous attempt)
4948
if (sessionId) {
50-
const existing = await rc.env.DATABASE.prepare(
51-
`SELECT id FROM agent_sessions WHERE id = ?`
52-
).bind(sessionId).first<{ id: string }>();
49+
const existing = await rc.env.DATABASE.prepare(`SELECT id FROM agent_sessions WHERE id = ?`)
50+
.bind(sessionId)
51+
.first<{ id: string }>();
5352

5453
if (!existing) {
5554
// StepResults had a sessionId but it's gone from D1 — reset and recreate

apps/api/src/routes/workspaces/runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ function toProjectDataMessages(body: MessageBatchBody) {
401401
toolMetadata: m.toolMetadata ? safeParseJson(m.toolMetadata) : null,
402402
timestamp: m.timestamp,
403403
sequence: m.sequence,
404+
origin: m.origin ?? null,
404405
}));
405406
}
406407

apps/api/src/schemas/workspaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ const MessageEntrySchema = v.object({
6868
toolMetadata: v.optional(v.nullable(v.string())),
6969
timestamp: v.string(),
7070
sequence: v.optional(v.number()),
71+
// "system" for SAM-injected messages the UI collapses; absent for normal messages.
72+
origin: v.optional(v.nullable(v.picklist(['user', 'system']))),
7173
});
7274

7375
export const MessageBatchSchema = v.object({

0 commit comments

Comments
 (0)