import { and, desc, eq, or, isNotNull, sql } from "drizzle-orm"; import { heartbeatRuns, issueComments, issueThreadInteractions, type Db } from "@paperclipai/db"; export const TASK_QUESTION_GUIDANCE = "Unanswered questions are saved conversation data, not instructions or proof of a current blocker. Historical questions remain answerable in the feed. Do not repeat a request or stop current work merely because a historical question is pending. Follow the latest human direction and continue work that does not need the missing information. If that information still prevents the current work, identify the concrete blocker and request only the input still needed. Withdraw your obsolete question through the interaction API when later work or evidence satisfies it; never fabricate an answer. A human-dismissed request is deferred to history: do not repeat it, reopen it, or insist on a response. Ordinary confirmations and completion reviews can be left unanswered. Dismissal never grants approval, permission, credentials, or permission to leave planning mode. Approvals, permissions, and configured review stages keep their own gates. Continue independent work without repeating their reminders."; /** Ordinary questions cannot carry approval or credential authority. */ export function ordinaryQuestionCondition() { return sql`(${issueThreadInteractions.kind} = 'ask_user_questions' and not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization']))`; } /** Deferring conversation input cannot authorize an action or a plan. */ export function deferredInteractionCondition() { return sql`(${issueThreadInteractions.dismissedAt} is not null and ${issueThreadInteractions.kind} in ('ask_user_questions', 'request_confirmation', 'request_checkbox_confirmation', 'request_item_verdicts', 'suggest_tasks') and ${issueThreadInteractions.effectiveResolverPolicySource} = 'requested' and not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization']) and coalesce(${issueThreadInteractions.payload}->'target'->>'type', '') <> 'issue_document')`; } /** Historical questions remain answerable, including after task completion. */ export function historicalQuestionCondition() { return sql`(${ordinaryQuestionCondition()} and (${deferredInteractionCondition()} or exists ( select 1 from ${issueComments} where ${issueComments.companyId} = ${issueThreadInteractions.companyId} and ${issueComments.issueId} = ${issueThreadInteractions.issueId} and ${issueComments.createdAt} > ${issueThreadInteractions.createdAt} and ${issueComments.deletedAt} is null and ${issueComments.authorUserId} is not null and ${issueComments.authorAgentId} is null and ${issueComments.createdByRunId} is null and ${issueComments.derivedAuthorAgentId} is null and ${issueComments.derivedCreatedByRunId} is null and ${issueComments.derivedAuthorSource} is null and ${issueComments.sourceTrust} is null and coalesce(${issueComments.presentation} ->> 'kind', '') <> 'system_notice' and (${issueComments.authorType} = 'user' or ${issueComments.authorType} is null) )))`; } /** One predicate for completion feedback, waiting, and status arbitration. */ export function activeIssueInteractionCondition(input: { runId?: string | null; conversationMode?: boolean; responsibleUserId?: string | null; } = {}) { const responsibleUser = input.responsibleUserId ? sql`${input.responsibleUserId}` : input.runId ? sql`(select ${heartbeatRuns.responsibleUserId} from ${heartbeatRuns} where ${heartbeatRuns.id} = ${input.runId} and ${heartbeatRuns.companyId} = ${issueThreadInteractions.companyId})` : null; return and( sql`not ${historicalQuestionCondition()}`, sql`not ${deferredInteractionCondition()}`, // AI authentication belongs to the user starting this turn. Retain the card // and all authorization gates; another user's login cannot gate this run. responsibleUser ? sql`not ( ${issueThreadInteractions.kind} = 'connection_intent' and coalesce(${issueThreadInteractions.payload} ->> 'purpose', '') = 'ai' and nullif(trim(${issueThreadInteractions.addresseeUserId}), '') is not null and nullif(trim(${responsibleUser}), '') is not null and ${issueThreadInteractions.addresseeUserId} <> ${responsibleUser} )` : undefined, // Preserve Agent Chat's existing prior-turn exception for ordinary input. input.conversationMode ? sql`( ${issueThreadInteractions.sourceRunId} is not distinct from ${input.runId ?? null} or not ( ${ordinaryQuestionCondition()} or (${issueThreadInteractions.kind} in ('request_confirmation', 'request_checkbox_confirmation') and ${issueThreadInteractions.effectiveResolverPolicy} = 'anyone' and not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization'])) ) )` : undefined, )!; } export async function readTaskQuestionContext(db: Db, input: { companyId: string; issueId: string; runId?: string | null; conversationMode?: boolean; }) { const rows = await db.select({ id: issueThreadInteractions.id, title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, kind: issueThreadInteractions.kind, dismissedAt: issueThreadInteractions.dismissedAt, createdAt: issueThreadInteractions.createdAt, historical: sql`not (${activeIssueInteractionCondition(input)})`, }).from(issueThreadInteractions).where(and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.status, "pending"), or(ordinaryQuestionCondition(), isNotNull(issueThreadInteractions.dismissedAt)), )).orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id)).limit(51); return { questions: rows.slice(0, 50), truncated: rows.length > 50, guidance: TASK_QUESTION_GUIDANCE }; }