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
51 changes: 51 additions & 0 deletions packages/junior-evals/evals/conversation/continuity.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import { slackMention, reply } from "@junior-evals/fixture/inputs";
import { rubric, RubricJudge } from "@junior-evals/fixture/judge";
import { test } from "@junior-evals/fixture/test";

// Junior compacts agent history above 90% of the context window, at about 4
// characters per token. This build log is about 56,000 tokens, so it does not
// fit a 50,000 token window. Checksums are pseudo-random, so a summary cannot
// recreate one.
const SMALL_CONTEXT_WINDOW_TOKENS = 50_000;
const checksum = (step: number) =>
(Math.imul(step + 1, 2_654_435_761) >>> 0).toString(16).padStart(8, "0");
const BUILD_LOG = Array.from(
{ length: 3_500 },
(_, index) =>
`2026-03-01T04:${String(index % 60).padStart(2, "0")}:00Z step ${String(index + 1).padStart(4, "0")} ok checksum ${checksum(index + 1)} region iad1`,
).join("\n");

describe("Thread Continuity", () => {
test("when a follow-up asks about the prior turn, recall the earlier budget context", async ({
run,
Expand All @@ -25,4 +38,42 @@ describe("Thread Continuity", () => {

expect(conversation.replies).toHaveLength(1);
});

test("when a compacted tool result holds an exact detail, read the full result back", async ({
agent,
}) => {
const { run } = await agent({
limits: { contextWindowTokens: SMALL_CONTEXT_WINDOW_TOKENS },
});
const conversation = await run(
slackMention(
"What was the exact checksum on step 0042 of that build log? I need to paste it into the release ticket.",
),
{
history: [
slackMention("Can you pull the full build log for build 7421?"),
reply("Build 7421 finished. All 3,500 steps passed in iad1.", {
toolHistory: [
{
name: "bash",
arguments: { command: "cat logs/build-7421.log" },
result: {
exit_code: 0,
stdout: BUILD_LOG,
stderr: "",
timed_out: false,
},
},
],
}),
],
},
);

expect(conversation.compactions).toBeGreaterThan(0);
// The checksum is only in the compacted tool result.
expect(conversation.replies.map((item) => item.text).join("\n")).toContain(
checksum(42),
);
});
});
233 changes: 233 additions & 0 deletions packages/junior/src/chat/conversations/event-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* Read access to a Conversation's durable event log for model-facing tools.
* The current Conversation tree is always readable. Other Conversations are
* readable only when they are public and in the same Slack workspace.
*/
import {
canExposeConversationPayload,
type ConversationPrivacy,
} from "@/chat/conversation-privacy";
import type { ConversationEvent } from "@/chat/conversations/history";
import type { ConversationStore } from "@/chat/conversations/store";
import { getConversationStore } from "@/chat/db";
import { ToolInputError } from "@/chat/tools/execution/tool-input-error";
import type { ToolRuntimeContext } from "@/chat/tools/types";

interface ConversationEventAccessScope {
currentConversationId: string;
currentRootConversationId?: string;
provider?: string;
providerTenantId?: string;
}

/**
* Throw a repairable tool error unless the active context may read the event
* log of `conversationId`.
*/
export async function assertCanReadConversationEvents(
context: ToolRuntimeContext,
conversationId: string,
): Promise<void> {
const conversationStore = getConversationStore();
const target = await conversationStore.get({ conversationId });
if (!target) {
throw new ToolInputError(`Conversation not found: ${conversationId}`);
}
if (target.transcriptPurgedAtMs !== undefined) {
throw new ToolInputError(
`Conversation transcript was purged: ${conversationId}`,
);
}

const accessScope = await resolveAccessScope(
conversationStore,
context,
context.conversationId,
);
// A Conversation with a parent uses the root privacy boundary.
const targetRootConversationId = await resolveRootConversationId(
conversationStore,
conversationId,
target.parentConversationId,
);
const targetRoot =
targetRootConversationId === conversationId
? target
: await conversationStore.get({
conversationId: targetRootConversationId,
});
assertScopeCanReadTarget({
accessScope,
targetConversationId: conversationId,
targetRootConversationId,
targetVisibility: targetRoot?.visibility ?? target.visibility,
targetDestination: targetRoot?.destination ?? target.destination,
});
}

async function resolveAccessScope(
conversationStore: ConversationStore,
context: ToolRuntimeContext,
currentConversationId: string,
): Promise<ConversationEventAccessScope> {
const current = await conversationStore.get({
conversationId: currentConversationId,
});
const currentRootConversationId = current
? await resolveRootConversationId(
conversationStore,
currentConversationId,
current.parentConversationId,
)
: currentConversationId;

if (context.location?.provider === "slack") {
return {
currentConversationId,
currentRootConversationId,
provider: "slack",
providerTenantId: context.location.teamId,
};
}

// TODO(dcramer): Remove the Source and Destination fallbacks after every
// deployed Slack Conversation has a stored Location and AgentRun no longer
// has Destination.
if (context.source.kind === "slack") {
return {
currentConversationId,
currentRootConversationId,
provider: "slack",
providerTenantId: context.source.teamId,
};
}

if (
context.destination.platform === "slack" &&
"teamId" in context.destination
) {
return {
currentConversationId,
currentRootConversationId,
provider: "slack",
providerTenantId: context.destination.teamId,
};
}

return {
currentConversationId,
currentRootConversationId,
};
}

async function resolveRootConversationId(
conversationStore: ConversationStore,
conversationId: string,
parentConversationId: string | undefined,
): Promise<string> {
if (!parentConversationId) {
return conversationId;
}

let cursor: string | undefined = parentConversationId;
const seen = new Set<string>([conversationId]);
while (cursor) {
if (seen.has(cursor)) {
// Fail closed on cycles; callers treat missing root as unauthorized.
return conversationId;
}
seen.add(cursor);
const parent = await conversationStore.get({ conversationId: cursor });
if (!parent) {
return conversationId;
}
if (!parent.parentConversationId) {
return parent.conversationId;
}
cursor = parent.parentConversationId;
}
return conversationId;
}

function assertScopeCanReadTarget(args: {
accessScope: ConversationEventAccessScope;
targetConversationId: string;
targetRootConversationId?: string;
targetVisibility?: ConversationPrivacy;
targetDestination?: {
platform: string;
teamId?: string;
channelId?: string;
conversationId?: string;
};
}): void {
const {
accessScope,
targetConversationId,
targetRootConversationId,
targetVisibility,
targetDestination,
} = args;

if (
targetConversationId === accessScope.currentConversationId ||
(targetRootConversationId !== undefined &&
targetRootConversationId === accessScope.currentRootConversationId)
) {
return;
}

const publicPayloadAllowed = canExposeConversationPayload(
targetVisibility ? { visibility: targetVisibility } : {},
);
if (!publicPayloadAllowed) {
throw new ToolInputError(
`Conversation events are not accessible: ${targetConversationId}`,
);
}

if (
accessScope.provider === "slack" &&
targetDestination?.platform === "slack" &&
accessScope.providerTenantId &&
targetDestination.teamId === accessScope.providerTenantId
) {
return;
}

throw new ToolInputError(
`Conversation events are not accessible: ${targetConversationId}`,
);
}

/** Replace replacement history with its item count; the original events stay readable by seq. */
export function stripReplacementHistory(
data: ConversationEvent["data"],
): ConversationEvent["data"] | Record<string, unknown> {
if (data.type === "unknown") {
return {
...data,
payload: stripReplacementHistoryField(data.payload),
};
}
if (data.type === "compaction" || data.type === "handoff") {
return stripReplacementHistoryField(data) as Record<string, unknown>;
}
return data;
}

function stripReplacementHistoryField(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const { replacementHistory, ...rest } = value as Record<string, unknown>;
if (replacementHistory === undefined) {
return value;
}
return {
...rest,
replacement_history_count: Array.isArray(replacementHistory)
? replacementHistory.length
: 0,
};
}
2 changes: 1 addition & 1 deletion packages/junior/src/chat/conversations/tool-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Deferred tool catalog source for retained Junior conversations. */
export const CONVERSATIONS_TOOL_SOURCE = {
id: "conversations",
description: "Search messages and events in retained conversations.",
description: "Search and read messages and events in retained conversations.",
} as const;
2 changes: 2 additions & 0 deletions packages/junior/src/chat/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { createUserCustomizationTools } from "@/chat/tools/user-customization";
import { createPublishImageTool } from "@/chat/tools/publish-image";
import { createUnpublishImageTool } from "@/chat/tools/unpublish-image";
import { createLoadAttachmentTool } from "@/chat/tools/load-attachment";
import { createReadConversationEventTool } from "@/chat/tools/read-conversation-event";
import { createSearchConversationEventsTool } from "@/chat/tools/search-conversation-events";
import { createSearchConversationBriefsTool } from "@/chat/tools/search-conversation-briefs";
import { createHandoffTool } from "@/chat/tools/handoff/tool";
Expand Down Expand Up @@ -145,6 +146,7 @@ export function createTools(
...createUserCustomizationTools(context),
};
tools.searchConversationEvents = createSearchConversationEventsTool(context);
tools.readConversationEvent = createReadConversationEventTool(context);
if (context.conversationPrivacy === "public") {
tools.searchConversationBriefs = createSearchConversationBriefsTool(
slackContext
Expand Down
Loading
Loading