Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/junior/src/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/junior/src/chat/conversations/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
53 changes: 42 additions & 11 deletions packages/junior/src/chat/conversations/sql/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,44 @@ 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);
}
// 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;
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;
Comment thread
cursor[bot] marked this conversation as resolved.
}

/** Split validated event data into column-lifted and JSON payload fields. */
function insertFromEvent(
conversationId: string,
Expand Down Expand Up @@ -169,7 +207,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)
Expand Down Expand Up @@ -222,16 +263,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
Expand Down
172 changes: 169 additions & 3 deletions packages/junior/tests/component/conversation-storage-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
: {}),
},
};
}

Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
},
Expand All @@ -592,6 +612,152 @@ 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,
},
{
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_400),
transcriptPurgedAt: null,
updatedAt: new Date(3_400),
});

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);
Expand Down
Loading