Files
PaperClipAI/tests/runner-e2e/chat-catalog-contract.ts
T

202 lines
13 KiB
TypeScript

import type { ChatFlowInput, ChatIssue, ChatRun } from "./chat-flow.js";
import type { CredentialName } from "./types.js";
import type { ChatExternalInputRequirement } from "./chat-private-inputs.js";
import type { ChatScenario } from "./chat-feature-cases.js";
export interface ChatCatalogCheck { id: string; passed: boolean; detail: string }
export interface ChatCatalogGrade { passed: boolean; checks: ChatCatalogCheck[] }
export interface ChatCatalogContext {
/** Original flow observation deadline; optional opt-in for bounded human followup acts. */
readonly observationDeadlineAt?: number;
input: ChatFlowInput;
scenario: ChatScenario;
issue(): ChatIssue | undefined;
allRuns(): Promise<ChatRun[]>;
refreshIssue(): Promise<void>;
/** Available only to the two allowlisted native Slack ingress cases. */
selectSlackSource?(issueId: string): Promise<void>;
}
/** These exact selected OpenCode/local journeys use the original flow clock for
* native observation phases. Other cases/lanes keep their authored phase caps. */
const originalClockExecutionIds = new Set([
"chat-features.runner-opencode.local.cli-operations.feedback-bundle",
"chat-features.runner-opencode.local.cli-operations.inspect-run-cli",
"chat-features.runner-opencode.local.cli-operations.owned-dev-instance",
"chat-features.runner-opencode.local.developer-labs.component-reference",
"chat-features.runner-opencode.local.developer-labs.gated-lab",
"chat-features.runner-opencode.local.developer-labs.interaction-fixture",
"chat-features.runner-opencode.local.recovery.browser-reconnect",
"chat-features.runner-opencode.local.recovery.controller-restart",
"chat-features.runner-opencode.local.recovery.explain-stall",
"chat-features.runner-opencode.local.recovery.retry-exhausted",
"chat-features.runner-opencode.local.recovery.source-action",
"chat-features.runner-opencode.local.routines.pause-owned",
"chat-features.runner-opencode.local.routines.run-now",
"chat-features.runner-opencode.local.steering.queue-follow-up",
"chat-features.runner-opencode.local.steering.stop-versus-pause",
"chat-features.runner-opencode.local.task-coordination.delegate-with-context",
"chat-features.runner-opencode.local.task-coordination.independent-review",
"chat-features.runner-opencode.local.task-coordination.pause-tree",
"chat-features.runner-opencode.local.tasks.edit-properties",
"chat-features.runner-opencode.local.steering.edit-queued-message",
"chat-features.runner-opencode.local.steering.discard-queued-message",
"chat-features.runner-opencode.local.instance-operations.backup-and-restore",
"chat-features.runner-opencode.local.instance-operations.restart-service",
"chat-features.runner-opencode.local.routines.webhook-trigger",
"chat-features.runner-opencode.local.status-cards.explain-card",
"chat-features.runner-opencode.local.secrets.configure-company-secret",
"chat-features.runner-opencode.local.teams.configure-team",
"chat-features.runner-opencode.local.steering.interrupt-with-direction",
"chat-features.runner-opencode.local.companies.fast-responses",
]);
function usesOriginalNativePhaseClock(ctx: ChatCatalogContext): boolean {
return originalClockExecutionIds.has(ctx.input?.execution?.id);
}
export function chatCatalogNativePhaseDeadline(ctx: ChatCatalogContext, legacyDeadlineAt: number): number {
if (!usesOriginalNativePhaseClock(ctx)) return legacyDeadlineAt;
const deadline = ctx.observationDeadlineAt, now = Date.now();
if (!Number.isFinite(deadline) || deadline! <= now) throw new Error("Original native observation budget exhausted or missing");
return deadline!;
}
/** Recheck the same clock before each declared human send operation. */
export function chatCatalogNativePhaseRemainingBudget(ctx: ChatCatalogContext): (() => number) | undefined {
if (!usesOriginalNativePhaseClock(ctx)) return undefined;
return () => {
const now = Date.now(), deadline = ctx.observationDeadlineAt;
if (!Number.isFinite(deadline) || deadline! <= now) throw new Error("Original native observation budget exhausted or missing");
return deadline! - now;
};
}
/** Only explicitly selected lanes opt identity helpers into the original clock. */
export function chatCatalogOriginalPhaseDeadline(ctx: ChatCatalogContext): number | undefined {
return usesOriginalNativePhaseClock(ctx) ? chatCatalogNativePhaseDeadline(ctx, 0) : undefined;
}
export function chatCatalogNativePhaseIsTimely(ctx: ChatCatalogContext): boolean {
return !usesOriginalNativePhaseClock(ctx) || Number.isFinite(ctx.observationDeadlineAt) && Date.now() < ctx.observationDeadlineAt!;
}
export type ChatCatalogTerminalRunStatus = "succeeded" | "cancelled" | "failed" | "timed_out";
export type ChatCatalogRunPolicy =
| { kind: "terminal-counts"; terminalStatusCounts: Partial<Record<ChatCatalogTerminalRunStatus, number>> }
| { kind: "source-settled-with-active-work"; maximumActiveWorkerRuns: number;
expectedWorkerCancellation?: "owned-task-reassignment" };
export interface ChatCatalogExpectedWorkerCancellation {
runId: string; taskId: string; workerId: string; sourceRunId: string;
}
export interface ChatCatalogSetupPolicy {
funding?: "standard" | "preserve-scenario";
readiness?: "all-ready" | "preserve-scenario";
/** Required independent prerequisite checks when preserving a scenario-specific policy. */
invariantCheckIds: readonly string[];
}
export type ChatCatalogQueryBinding = { catalogToken: string; stateKey: string } & (
| { resourceKind: "issue"; valueField: "identifier" }
| { resourceKind: "project-repository"; valueField: "fullName" }
| { resourceKind: "agentmail-recipient"; valueField: "email" }
);
export interface ChatCatalogQueryAttachment {
/** A genuine UI upload receipt returned by setup; never arbitrary body text. */
stateKey: string;
filename: string;
}
export interface ChatCompanionSurface { kind: "scoped-external-assistant"; model: string; maximumModelTurns: 6; maximumToolCalls: 4; maximumOutputTokens: 2048 }
export interface ChatCompanionResult {
query: string; reply: string; model: string; usage: { inputTokens: number; outputTokens: number };
messages: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string }>;
providerResponseIds: string[]; toolCalls: Array<{ name: string; arguments: unknown; result: unknown }>; links: string[];
}
export interface ChatSlackIngress { kind: "native-slack-thread"; maximumProviderCalls: 20; ingressTimeoutMs: 120000; setupThread?: "same-agent-distractor" }
export interface ChatSlackIngressReceipt {
query: string; endpointId: string; conversationId: string; issueId: string; humanCommentId: string; linkedPaperclipUserId: string; providerCalls: number;
provider: { workspaceId: string; channelId: string; threadId: string; canonicalMessageId: string; senderUserId: string };
}
/** One concrete scenario, with production API setup and a case-specific independent oracle. */
export interface NativeStopBoundaryReceipt { runId: string; issueId: string; intentId: string; intentRecordedAt: string }
export interface ChatCatalogCase {
/** Only post-stop-message: Stop the genuine same-source setup turn after bookkeeping, then send the unchanged canonical query. */
canonicalBoundary?: "native-stop-cleanup";
beforeCanonicalSend?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<NativeStopBoundaryReceipt>;
scenarioId: string;
/** Real browser locale context for the canonical person's schedule. */
browserTimezone?: "America/Chicago";
respondingAgent?: { kind: "case-primary-owner"; name: "Morgan"; role: "engineer"; canAssignTasks: true };
requiredCredentials?: readonly CredentialName[];
externalInput?: ChatExternalInputRequirement;
installedSource?: "published-global-npm";
journeyKind?: "agent-chat" | "scoped-external-assistant" | "native-slack-thread";
slackIngress?: ChatSlackIngress;
/** Only reply-in-thread: one real other-source setup exchange, included in total native bounds. */
dispatchSlackOtherThread?(context: ChatCatalogContext, state: Record<string, unknown>, input: { query: string; signal: AbortSignal }): Promise<ChatSlackIngressReceipt & { runId: string }>;
dispatchSlackQuery?(context: ChatCatalogContext, state: Record<string, unknown>, input: { query: string; signal: AbortSignal }): Promise<ChatSlackIngressReceipt>;
companionSurface?: ChatCompanionSurface;
runCompanionSurface?(context: ChatCatalogContext, state: Record<string, unknown>, input: { query: string; signal: AbortSignal }): Promise<ChatCompanionResult>;
/** Only channel-file: independently settle the supported transfer before a second actual upload. */
verifySlackSupportedTransfer?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<ChatCatalogGrade>;
dispatchSlackUnsupportedFile?(context: ChatCatalogContext, state: Record<string, unknown>, input: { query: string; signal: AbortSignal }): Promise<ChatSlackIngressReceipt>;
authentication?: "local-trusted" | "human-session";
setupPolicy?: ChatCatalogSetupPolicy;
runPolicy?: ChatCatalogRunPolicy;
/** Only edit-properties: independent observation proof of the exact reassigned worker. */
expectedCancelledWorkerRun?(observation: unknown): ChatCatalogExpectedWorkerCancellation | undefined;
queryOutcome?: "persisted" | "rejected";
/** Audited substitution of an incidental catalog fixture token, never other prose. */
queryBindings?: readonly ChatCatalogQueryBinding[];
queryAttachments?: readonly ChatCatalogQueryAttachment[];
/** All case/grader helpers must be listed; the suite also fingerprints its complete source tree. */
sourceFiles: readonly string[];
oracleVersion: string;
/** Known missing catalog conditions keep this executable diagnostic case from acceptance. */
coverageGaps?: readonly string[];
expectedRunCount: number;
/** Persisted human comments; explicit followups must be declared before execution. */
expectedHumanMessageCount?: number;
/** Genuine persisted human setup turns before the canonical query; included in the total above. */
setupHumanMessageCount?: number;
minimumRunCount?: number;
turnTimeoutMs: number;
attemptTimeoutMs: number;
verifySetup?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<ChatCatalogGrade>;
verifyQueryRejection?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<ChatCatalogGrade>;
setup(context: ChatCatalogContext): Promise<Record<string, unknown>>;
/** Only explicit scenario user followups/approvals through visible production UI. */
act?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<void>;
observe(context: ChatCatalogContext, state: Record<string, unknown>): Promise<unknown>;
grade(observation: unknown): ChatCatalogGrade;
/** Open the actual linked result through the browser and verify its identity. */
verifyResult(context: ChatCatalogContext, state: Record<string, unknown>, observation: unknown): Promise<void>;
cleanup?(context: ChatCatalogContext, state: Record<string, unknown>): Promise<void>;
}
export function validateChatCatalogGrade(grade: ChatCatalogGrade): ChatCatalogGrade {
if (!grade || !Array.isArray(grade.checks) || grade.checks.length === 0
|| grade.checks.some(check => !check.id || typeof check.passed !== "boolean" || !check.detail)
|| new Set(grade.checks.map(check => check.id)).size !== grade.checks.length
|| grade.passed !== grade.checks.every(check => check.passed)) {
throw new Error("Invalid chat catalog oracle result: checks must be nonempty, unique and agree with the verdict");
}
return grade;
}
export interface ChatCatalogSetupBoundary { humanCommentIds: string[]; runIds: string[] }
export function validChatCatalogHumanCounts(implementation: Pick<ChatCatalogCase, "setupHumanMessageCount" | "expectedHumanMessageCount" | "queryOutcome">): boolean {
const setup = implementation.setupHumanMessageCount ?? 0, total = implementation.expectedHumanMessageCount ?? 1;
return Number.isSafeInteger(setup) && setup >= 0 && Number.isSafeInteger(total)
&& total >= setup + (implementation.queryOutcome === "rejected" ? 0 : 1);
}
export function chatCatalogHumanHistoryMatches(input: {
implementation: Pick<ChatCatalogCase, "setupHumanMessageCount" | "expectedHumanMessageCount" | "queryOutcome">;
human: Array<{ id: string; body: string }>; runIds: string[];
boundary: ChatCatalogSetupBoundary; renderedQuery: string;
}): boolean {
const count = input.implementation.setupHumanMessageCount ?? 0;
return validChatCatalogHumanCounts(input.implementation)
&& input.boundary.humanCommentIds.length === count
&& input.boundary.humanCommentIds.every((id, index) => input.human[index]?.id === id)
&& input.boundary.runIds.every(id => input.runIds.includes(id))
&& (input.implementation.queryOutcome === "rejected"
? input.human.length === count
: input.human[count]?.body.trim() === input.renderedQuery.trim()
&& input.human.length === (input.implementation.expectedHumanMessageCount ?? 1));
}