From 70dfd893302944fa8a848ef7599b2851c19c28a6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:26:20 +0000 Subject: [PATCH 1/2] fix(chat): Keep archive through system noise Auto-unarchive previously fired on every non-preserve event append and on history replacement. Resource events, turn lifecycle, and compaction could restore archived conversations without a human message. Clear archived_at only for human user instructions or human visible user messages. Keep activity clock updates for system noise. Co-Authored-By: Pierre Massat --- packages/junior/src/chat/README.md | 3 + .../junior/src/chat/conversations/history.ts | 5 +- .../src/chat/conversations/sql/history.ts | 52 ++++-- .../conversation-storage-sql.test.ts | 155 +++++++++++++++++- 4 files changed, 200 insertions(+), 15 deletions(-) diff --git a/packages/junior/src/chat/README.md b/packages/junior/src/chat/README.md index 199e06200..16fd133d7 100644 --- a/packages/junior/src/chat/README.md +++ b/packages/junior/src/chat/README.md @@ -104,6 +104,9 @@ delegation without becoming the execution actor or a general task owner. - Durable state is committed before acknowledging queue work or yielding. - Conversation events emitted by plugin operations preserve conversation activity, archive, and transcript-retention state. +- Archive stays set through system noise (resource events, turn lifecycle, + compaction/handoff). Only a human user instruction or human visible user + message restores an archived conversation to the feed. - Model input stays below the configured bot context cap and the active model's advertised window. The agent checks before its first provider request and after each tool batch; an in-turn compaction commits its history replacement diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index dc5d79dfc..e6275c090 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -539,7 +539,10 @@ export interface ConversationEventPage { /** Persist and read the canonical per-conversation event log. */ export interface ConversationEventStore { - /** Append events atomically, optionally preserving conversation activity. */ + /** + * Append events atomically, optionally preserving conversation activity. + * Archive clears only for human user activity, not every non-preserve write. + */ append( conversationId: string, events: NewConversationEvent[], diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 2790e9193..9b3a10a43 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -51,6 +51,43 @@ const messageHistoryEventTypes = [ "message_handled", ] as const; +const HUMAN_INSTRUCTION_PLATFORMS = new Set(["slack", "local", "web"]); + +/** + * Whether one event should restore an archived conversation to the feed. + * + * Archive hides finished noise until a human comes back. Resource events, + * turn lifecycle, compaction, and other system writes may still refresh + * activity clocks, but they must not unarchive on their own. + */ +function eventUnarchivesConversation(data: ConversationEventData): boolean { + if (data.type === "user_message") { + const provenance = data.provenance; + if (provenance.authority !== "instruction") return false; + const platform = provenance.actor?.platform; + return platform === undefined || HUMAN_INSTRUCTION_PLATFORMS.has(platform); + } + if (data.type !== "message" && data.type !== "message_updated") { + return false; + } + if (data.role !== "user") return false; + const meta = data.meta; + if (!meta) return true; + if (typeof meta.eventType === "string" && meta.eventType.length > 0) { + return false; + } + const author = meta.author; + if ( + author && + typeof author === "object" && + !Array.isArray(author) && + (author as { isBot?: unknown }).isBot === true + ) { + return false; + } + return true; +} + /** Split validated event data into column-lifted and JSON payload fields. */ function insertFromEvent( conversationId: string, @@ -169,7 +206,10 @@ class SqlConversationEventStore implements ConversationEventStore { newestCreatedAtMs, options, ); - if (options.activity !== "preserve") { + if ( + options.activity !== "preserve" && + pending.some((event) => eventUnarchivesConversation(event.data)) + ) { await this.executor .db() .update(juniorConversations) @@ -222,16 +262,6 @@ class SqlConversationEventStore implements ConversationEventStore { const parsed = historyReplacementSchema.parse(replacement); await withConversationEventLock(this.executor, conversationId, async () => { await ensureConversationRow(this.executor, conversationId, Date.now()); - await this.executor - .db() - .update(juniorConversations) - .set({ archivedAt: null }) - .where( - and( - eq(juniorConversations.conversationId, conversationId), - isNotNull(juniorConversations.archivedAt), - ), - ); const cursor = await this.readCursor(conversationId); const historyVersion = (cursor.maxHistoryVersion ?? 0) + 1; await this.executor diff --git a/packages/junior/tests/component/conversation-storage-sql.test.ts b/packages/junior/tests/component/conversation-storage-sql.test.ts index 9f42db3a0..5adf09d63 100644 --- a/packages/junior/tests/component/conversation-storage-sql.test.ts +++ b/packages/junior/tests/component/conversation-storage-sql.test.ts @@ -317,13 +317,33 @@ function userMessage(text: string) { function userMessageEvent( text: string, authority: "instruction" | "context" = "context", + actor?: { platform: "slack" | "local" | "web" | "system"; name?: string }, ) { const { content, timestamp } = userMessage(text); return { type: "user_message" as const, content, timestamp, - provenance: { authority }, + provenance: { + authority, + ...(actor + ? { + actor: + actor.platform === "system" + ? { platform: "system" as const, name: actor.name ?? "system" } + : actor.platform === "slack" + ? { + platform: "slack" as const, + teamId: "T123", + userId: "U123", + } + : { + platform: actor.platform, + userId: "user-1", + }, + } + : {}), + }, }; } @@ -527,7 +547,7 @@ describe("SQL conversation storage", () => { await migrateSchema(fixture.sql); const store = createSqlConversationEventStore(fixture.sql); const firstEvent = { - data: userMessageEvent("first"), + data: userMessageEvent("first", "instruction"), idempotencyKey: "event:first", createdAtMs: 1_000, }; @@ -566,7 +586,7 @@ describe("SQL conversation storage", () => { await store.append(CONVERSATION_ID, [ { ...firstEvent, createdAtMs: 10_000 }, { - data: userMessageEvent("second"), + data: userMessageEvent("second", "instruction"), idempotencyKey: "event:second", createdAtMs: 8_000, }, @@ -592,6 +612,135 @@ describe("SQL conversation storage", () => { } }); + it("keeps archive through system noise and restores only on human activity", async () => { + const fixture = await createLocalJuniorSqlFixture(); + + try { + await migrateSchema(fixture.sql); + const store = createSqlConversationEventStore(fixture.sql); + await store.append(CONVERSATION_ID, [ + { + data: userMessageEvent("seed", "instruction", { platform: "slack" }), + idempotencyKey: "event:seed", + createdAtMs: 1_000, + }, + ]); + await fixture.sql + .db() + .update(juniorConversations) + .set({ + archivedAt: new Date(2_000), + transcriptPurgedAt: new Date(2_500), + }) + .where(eq(juniorConversations.conversationId, CONVERSATION_ID)); + + const readConversationTimestamps = async () => { + const [row] = await fixture.sql + .db() + .select({ + archivedAt: juniorConversations.archivedAt, + lastActivityAt: juniorConversations.lastActivityAt, + transcriptPurgedAt: juniorConversations.transcriptPurgedAt, + updatedAt: juniorConversations.updatedAt, + }) + .from(juniorConversations) + .where(eq(juniorConversations.conversationId, CONVERSATION_ID)); + return row; + }; + const archived = await readConversationTimestamps(); + + await store.append(CONVERSATION_ID, [ + { + data: { + type: "turn_started", + turnId: "turn-1", + inputMessageIds: ["msg-resource"], + surface: "slack", + }, + idempotencyKey: "event:turn-started", + createdAtMs: 3_000, + }, + { + data: { + type: "message", + messageId: "msg-resource", + role: "user", + text: "Pull request checks failed.", + meta: { + eventType: "pull_request.checks.failed", + author: { + userId: "UJRNEVENT", + userName: "junior-event", + isBot: true, + }, + }, + }, + idempotencyKey: "event:resource", + createdAtMs: 3_100, + }, + { + data: userMessageEvent("ambient", "context"), + idempotencyKey: "event:context", + createdAtMs: 3_200, + }, + { + data: userMessageEvent("system", "instruction", { + platform: "system", + name: "resource-event", + }), + idempotencyKey: "event:system-instruction", + createdAtMs: 3_300, + }, + ]); + + expect(await readConversationTimestamps()).toEqual({ + ...archived, + lastActivityAt: new Date(3_300), + transcriptPurgedAt: null, + updatedAt: new Date(3_300), + }); + + await store.replaceHistory(CONVERSATION_ID, { + createdAtMs: 4_000, + data: { + type: "compaction", + modelProfile: "coding", + modelId: "openai/gpt-5.4", + replacementHistory: [ + { + item: userMessageEvent("summary", "instruction", { + platform: "slack", + }), + }, + ], + }, + }); + + expect((await readConversationTimestamps())?.archivedAt).toEqual( + archived?.archivedAt, + ); + + await store.append(CONVERSATION_ID, [ + { + data: userMessageEvent("follow up", "instruction", { + platform: "slack", + }), + idempotencyKey: "event:human", + createdAtMs: 5_000, + }, + ]); + + const restored = await readConversationTimestamps(); + expect(restored?.archivedAt).toBeNull(); + expect(restored?.transcriptPurgedAt).toBeNull(); + // replaceHistory refreshes activity with Date.now(); the human append + // must still clear archive without regressing that clock. + expect(restored?.lastActivityAt?.getTime()).toBeGreaterThanOrEqual(5_000); + } finally { + await fixture.close(); + } + }); + it("deduplicates repeated keys within one append without leaving seq gaps", async () => { const fixture = await createLocalJuniorSqlFixture(); const store = createSqlConversationEventStore(fixture.sql); From b94412d7c60740e3d30da45563d3efe2d8558eb8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:12:27 +0000 Subject: [PATCH 2/2] fix(chat): Do not unarchive on message_updated message_updated is hydration or delivery on an existing transcript row. It is not a new human coming back, so it must not clear archived_at. --- .../src/chat/conversations/sql/history.ts | 3 ++- .../conversation-storage-sql.test.ts | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 9b3a10a43..2ce47e236 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -67,7 +67,8 @@ function eventUnarchivesConversation(data: ConversationEventData): boolean { const platform = provenance.actor?.platform; return platform === undefined || HUMAN_INSTRUCTION_PLATFORMS.has(platform); } - if (data.type !== "message" && data.type !== "message_updated") { + // message_updated is hydration/delivery on an existing row, not a new human. + if (data.type !== "message") { return false; } if (data.role !== "user") return false; diff --git a/packages/junior/tests/component/conversation-storage-sql.test.ts b/packages/junior/tests/component/conversation-storage-sql.test.ts index 5adf09d63..7f3265e72 100644 --- a/packages/junior/tests/component/conversation-storage-sql.test.ts +++ b/packages/junior/tests/component/conversation-storage-sql.test.ts @@ -691,13 +691,30 @@ describe("SQL conversation storage", () => { idempotencyKey: "event:system-instruction", createdAtMs: 3_300, }, + { + data: { + type: "message_updated", + messageId: "msg-seed", + role: "user", + text: "seed (hydrated)", + meta: { + author: { + userId: "U123", + userName: "pierre", + isBot: false, + }, + }, + }, + idempotencyKey: "event:message-updated", + createdAtMs: 3_400, + }, ]); expect(await readConversationTimestamps()).toEqual({ ...archived, - lastActivityAt: new Date(3_300), + lastActivityAt: new Date(3_400), transcriptPurgedAt: null, - updatedAt: new Date(3_300), + updatedAt: new Date(3_400), }); await store.replaceHistory(CONVERSATION_ID, {