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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Short, dated summary of notable fixes and changes. For the full "why," see `PHASE2-SETUP.md` (Crisp triage design) or the linked PRs.

## 2026-10-14 — Crisp triage: no re-investigation on support replies; dedupe skips quiet threads

- **Stale check re-arms only on a new customer message.** A support reply re-investigated `session_3931e910` three times, and the two runs that finished found `no_defect`. The reopen path is unchanged.
- **Dedupe skips threads with no customer message in 14 days.** `session_f9e20b9b` was resolved Aug 14, was `unresolved` again by Sep 2 for a reason the API doesn't show, and was matched to an open issue in October.
- **Staleness now comes from real message timestamps.** `active.last` is missing on email threads, so the old `active.last ?? created_at` counted age since creation.
- Not tested against live Crisp. After merge, check the `stale ...h` log lines and the dedupe step summary's new "Skipped" count.

## 2026-10-09 — PR build zip: optional "Try this PR in a live test site" link

For QA and for support reproducing issues: the PR build comment can now include a link that opens a fresh WordPress (Playground, in the browser) with that build installed, logged in as admin. No install, nothing to clean up.
Expand Down
45 changes: 45 additions & 0 deletions scripts/crisp-activity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Pure helpers for judging real conversation activity from fetched Crisp messages.
// Customer messages drive re-investigation; `active.last` is often absent and
// `updated_at` is bumped by our own notes, so neither proves real activity.

// crisp-dedupe-active.mjs skips threads whose newest customer message is older than this.
export const DEDUPE_MAX_QUIET_DAYS = 14;

const DAY_MS = 24 * 60 * 60 * 1000;

export function isCustomerText(message) {
return message.type === "text" && message.from === "user";
}

// Newest customer-or-agent text. Notes (including ours) and events are excluded.
export function newestTextTimestamp(messages, floor = 0) {
return messages.reduce((max, m) => (m.type === "text" ? Math.max(max, m.timestamp ?? 0) : max), floor);
}

// Newest customer-written message; agent replies, notes and events never count.
export function newestCustomerTextTimestamp(messages, floor = 0) {
return messages.reduce((max, m) => (isCustomerText(m) ? Math.max(max, m.timestamp ?? 0) : max), floor);
}

// Newest metadata timestamp, as a hint for whether to fetch messages at all.
// Callers must re-check against real messages before acting.
export function conversationActivityHint(conversation) {
return Math.max(conversation.active?.last ?? 0, conversation.updated_at ?? 0, conversation.created_at ?? 0);
}

// True if the customer wrote nothing within `maxQuietDays` (no customer text counts as quiet).
export function isCustomerQuiet(messages, now = Date.now(), maxQuietDays = DEDUPE_MAX_QUIET_DAYS) {
const newest = newestCustomerTextTimestamp(messages);
return newest === 0 || now - newest > maxQuietDays * DAY_MS;
}

// Reclassify only if never checked, or the customer wrote after the last check.
export function shouldReclassifyStale(messages, checkedThroughAt) {
if (checkedThroughAt === undefined) return true;
return newestCustomerTextTimestamp(messages) > checkedThroughAt;
}

// Marker to store after a stale check; never moves backwards past existing entries.
export function nextCheckedThroughAt(messages, checkedThroughAt) {
return newestCustomerTextTimestamp(messages, checkedThroughAt ?? 0);
}
108 changes: 108 additions & 0 deletions scripts/crisp-activity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
DEDUPE_MAX_QUIET_DAYS,
isCustomerText,
newestTextTimestamp,
newestCustomerTextTimestamp,
conversationActivityHint,
isCustomerQuiet,
shouldReclassifyStale,
nextCheckedThroughAt,
} from "./crisp-activity.mjs";

const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.parse("2026-10-14T00:00:00Z");
const at = (iso) => Date.parse(iso);

// Mirrors the real shape of session_3931e910 (types/roles/timestamps only).
const msgs = [
{ type: "text", from: "user", timestamp: at("2026-10-09T09:53:17Z") },
{ type: "note", from: "operator", timestamp: at("2026-10-09T15:38:02Z") },
{ type: "text", from: "operator", timestamp: at("2026-10-13T03:32:13Z") },
{ type: "note", from: "operator", timestamp: at("2026-10-13T12:08:29Z") },
];

test("isCustomerText: only text from the user", () => {
assert.equal(isCustomerText({ type: "text", from: "user" }), true);
assert.equal(isCustomerText({ type: "text", from: "operator" }), false);
assert.equal(isCustomerText({ type: "note", from: "user" }), false);
assert.equal(isCustomerText({ type: "file", from: "user" }), false);
});

test("newestTextTimestamp counts agent text but not notes/events", () => {
assert.equal(newestTextTimestamp(msgs), at("2026-10-13T03:32:13Z"));
assert.equal(newestTextTimestamp([{ type: "event", from: "operator", timestamp: 999 }]), 0);
});

test("newestCustomerTextTimestamp ignores agent replies and notes", () => {
assert.equal(newestCustomerTextTimestamp(msgs), at("2026-10-09T09:53:17Z"));
assert.equal(newestCustomerTextTimestamp([]), 0);
assert.equal(newestCustomerTextTimestamp([], 5), 5);
});

test("shouldReclassifyStale: an agent reply alone does NOT re-arm (3931e910 regression)", () => {
// Already checked through the customer's Oct 9 message; agent replied Oct 13.
assert.equal(shouldReclassifyStale(msgs, at("2026-10-09T09:53:17Z")), false);
});

test("shouldReclassifyStale: a newer customer message re-arms", () => {
const withNewCustomer = [...msgs, { type: "text", from: "user", timestamp: at("2026-10-13T20:00:00Z") }];
assert.equal(shouldReclassifyStale(withNewCustomer, at("2026-10-13T03:32:13Z")), true);
});

test("shouldReclassifyStale: never-checked conversation is classified once", () => {
assert.equal(shouldReclassifyStale(msgs, undefined), true);
assert.equal(shouldReclassifyStale([], undefined), true);
});

test("shouldReclassifyStale: existing marker stamped from agent text still holds", () => {
// Real state: checkedThroughAt = Oct 13 03:32 (an agent reply). Customer's
// newest message is older, so nothing re-arms.
assert.equal(shouldReclassifyStale(msgs, at("2026-10-13T03:32:13Z")), false);
});

test("nextCheckedThroughAt never moves backwards", () => {
const existing = at("2026-10-13T03:32:13Z");
assert.equal(nextCheckedThroughAt(msgs, existing), existing);
assert.equal(nextCheckedThroughAt(msgs, undefined), at("2026-10-09T09:53:17Z"));
assert.equal(nextCheckedThroughAt([], undefined), 0);
});

test("conversationActivityHint: takes the newest metadata, tolerates missing active.last", () => {
const convo = { created_at: 100, updated_at: 300 }; // active.last absent, like email threads
assert.equal(conversationActivityHint(convo), 300);
assert.equal(conversationActivityHint({ created_at: 100, updated_at: 300, active: { last: 500 } }), 500);
assert.equal(conversationActivityHint({ created_at: 100 }), 100);
});

test("isCustomerQuiet: f9e20b9b shape (one customer msg in Aug, bot note in Oct) is quiet", () => {
const f9e2 = [
{ type: "text", from: "user", timestamp: at("2026-08-06T16:11:57Z") },
{ type: "text", from: "operator", timestamp: at("2026-08-12T08:07:30Z") },
{ type: "event", from: "operator", timestamp: at("2026-08-14T04:11:34Z") },
{ type: "note", from: "operator", timestamp: at("2026-10-13T23:52:03Z") },
];
assert.equal(isCustomerQuiet(f9e2, NOW), true);
});

test("isCustomerQuiet: boundary around the window", () => {
const fresh = [{ type: "text", from: "user", timestamp: NOW - (DEDUPE_MAX_QUIET_DAYS - 1) * DAY }];
const old = [{ type: "text", from: "user", timestamp: NOW - (DEDUPE_MAX_QUIET_DAYS + 1) * DAY }];
assert.equal(isCustomerQuiet(fresh, NOW), false);
assert.equal(isCustomerQuiet(old, NOW), true);
});

test("isCustomerQuiet: a recent agent reply or note does not make it fresh", () => {
const m = [
{ type: "text", from: "user", timestamp: NOW - 30 * DAY },
{ type: "text", from: "operator", timestamp: NOW - 1 * DAY },
{ type: "note", from: "operator", timestamp: NOW - 1000 },
];
assert.equal(isCustomerQuiet(m, NOW), true);
});

test("isCustomerQuiet: no customer text at all counts as quiet", () => {
assert.equal(isCustomerQuiet([], NOW), true);
assert.equal(isCustomerQuiet([{ type: "text", from: "operator", timestamp: NOW }], NOW), true);
});
56 changes: 29 additions & 27 deletions scripts/crisp-classify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import {
credsForAccount,
} from "./crisp-client.mjs";
import { classifyAndRoute } from "./crisp-classifier.mjs";
import {
newestTextTimestamp,
conversationActivityHint,
shouldReclassifyStale,
nextCheckedThroughAt,
} from "./crisp-activity.mjs";
import { emitEvent } from "./events.mjs";

const { GITHUB_STEP_SUMMARY } = process.env;
Expand Down Expand Up @@ -61,16 +67,9 @@ function transcriptFrom(messages) {
.join("\n");
}

// Newest timestamp among real conversation messages only (customer/agent
// text). Private notes -- including this pipeline's own "Investigation
// report" notes -- and event messages are excluded: Stage 2 posts its note
// AFTER Stage 1 stamps the marker, so counting notes made every investigation
// look like "new activity" on the next run and re-investigated the same
// conversation every few hours (confirmed on session_bd0acc7b). Only the
// automatic stale/reopen paths use this; manual-note branches are unchanged.
function newestTextTimestamp(messages, floor = 0) {
return messages.reduce((max, m) => (m.type === "text" ? Math.max(max, m.timestamp ?? 0) : max), floor);
}
// newestTextTimestamp excludes notes, including our own: counting them made every
// investigation look like new activity (session_bd0acc7b). The stale path also
// re-arms only on customer messages (see crisp-activity.mjs).

// A rate-limit failure (crispFetch already retried and gave up) on ONE session
// shouldn't kill the whole run -- that skips "Commit advanced state" and
Expand Down Expand Up @@ -180,8 +179,8 @@ async function main() {
const lastCheckedMs = new Date(cursor.last_checked).getTime();
const activeConversations = await fetchActiveConversations(creds);
activeConversations.forEach((conversation) => {
const lastActiveAt = conversation.active?.last ?? conversation.created_at;
conversation._checkManualNote = lastActiveAt > lastCheckedMs;
// Metadata hint only: `active.last` is absent on email-origin threads.
conversation._checkManualNote = conversationActivityHint(conversation) > lastCheckedMs;
});

// Search directly for the trigger phrase too, in case the page cap buries it.
Expand All @@ -198,22 +197,16 @@ async function main() {
const record = escalated[conversation.session_id] ?? { manualNoteCount: 0 };
const previousResolveAt = resolvedSeen[conversation.session_id];

// active.last, not created_at, so a reopened thread isn't read as ancient.
// NOTE: active.last (and even updated_at) can lag behind real message
// activity, especially for email-origin conversations -- confirmed for
// real (session_bd0acc7b showed live replies well after both fields
// had stopped moving). So this is only used to decide whether it's
// worth fetching messages at all, never as the final word on
// freshness -- see checkedThroughAt below, which is timestamped from
// the actual fetched messages instead.
const lastActiveAt = conversation.active?.last ?? conversation.created_at;
const staleHours = (Date.now() - lastActiveAt) / (1000 * 60 * 60);
const eligibleForAutoEscalate = staleHours >= AUTO_ESCALATE_HOURS && staleHours <= AUTO_ESCALATE_MAX_HOURS;
// Pre-fetch hint only. `active.last` is absent on email threads, and metadata
// can lag real activity (session_bd0acc7b), so staleness is re-checked
// against the fetched messages below.
const hintStaleHours = (Date.now() - conversationActivityHint(conversation)) / (1000 * 60 * 60);
const hintEligibleForAutoEscalate = hintStaleHours >= AUTO_ESCALATE_HOURS && hintStaleHours <= AUTO_ESCALATE_MAX_HOURS;

// Seen resolved before, active again now -- a reopen.
const isReopen = previousResolveAt !== undefined;

if (!conversation._checkManualNote && !eligibleForAutoEscalate && !isReopen) continue;
if (!conversation._checkManualNote && !hintEligibleForAutoEscalate && !isReopen) continue;

// On skip, nothing is recorded for this session, so the next run
// re-evaluates it from scratch.
Expand All @@ -222,6 +215,14 @@ async function main() {
const manualNoteCount = conversation._checkManualNote ? countManualTriggerNotes(messages) : record.manualNoteCount;
const hasNewManualNote = manualNoteCount > record.manualNoteCount;

// Authoritative staleness: time since the newest real text (notes excluded,
// since ours bump `updated_at`). Falls back to the hint if there is no text.
const newestRealTextAt = newestTextTimestamp(messages);
const staleHours = newestRealTextAt > 0
? (Date.now() - newestRealTextAt) / (1000 * 60 * 60)
: hintStaleHours;
const eligibleForAutoEscalate = staleHours >= AUTO_ESCALATE_HOURS && staleHours <= AUTO_ESCALATE_MAX_HOURS;

if (!hasNewManualNote && !eligibleForAutoEscalate && !isReopen) continue;

// A reopen only sees what's new since the previous resolve; a manual note gets everything.
Expand Down Expand Up @@ -290,11 +291,12 @@ async function main() {
// up, but can never be blocked forever the way a permanent
// already-investigated flag was (confirmed for real: session_2fc63232
// stayed stuck across repeated resolve/reopen cycles).
const newestMessageAt = newestTextTimestamp(messages);
if (record.checkedThroughAt === undefined || newestMessageAt > record.checkedThroughAt) {
// Re-arms only on a new customer message: support replies re-investigated
// an already-triaged thread twice with no_defect (session_3931e910).
if (shouldReclassifyStale(messages, record.checkedThroughAt)) {
const result = await classifyAndRoute(accountConfig, conversation, transcript);
recordScan("stale", accountKey, conversation.session_id, result);
record.checkedThroughAt = newestMessageAt;
record.checkedThroughAt = nextCheckedThroughAt(messages, record.checkedThroughAt);
if (result.repo && result.actionable && result.kind !== "none") {
matrix.push({ session_id: conversation.session_id, repo: result.repo, kind: result.kind, account: accountKey });
investigated.add(conversation.session_id);
Expand Down
20 changes: 17 additions & 3 deletions scripts/crisp-dedupe-active.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { readFile, writeFile } from "node:fs/promises";
import {
fetchActiveConversations,
getInboxKey,
fetchTranscript,
fetchRawMessages,
postNote,
credsForAccount,
conversationUrl,
} from "./crisp-client.mjs";
import { isCustomerQuiet, DEDUPE_MAX_QUIET_DAYS } from "./crisp-activity.mjs";
import { chatJSON } from "./openai-client.mjs";
import { listOpenIssues, listIssueComments, commentOnIssue } from "./github-client.mjs";

Expand Down Expand Up @@ -97,6 +98,7 @@ async function main() {

let checked = 0;
let matched = 0;
let skippedQuiet = 0;

for (const [accountKey, accountConfig] of Object.entries(accounts)) {
let creds;
Expand All @@ -115,9 +117,21 @@ async function main() {
if (escalatedThisRun.has(conversation.session_id)) continue;
checked++;

const transcript = await fetchTranscript(creds, conversation.session_id);
const messages = await fetchRawMessages(creds, conversation.session_id);
const transcript = messages
.filter((m) => m.type === "text")
.map((m) => `${m.from === "user" ? "Customer" : "Agent"}: ${m.content}`)
.join("\n");
if (!transcript.trim()) continue;

// Skip threads the customer went quiet on (session_f9e20b9b was matched two
// months after it was resolved). Not marked notified, so a new customer
// message re-evaluates it.
if (isCustomerQuiet(messages)) {
skippedQuiet++;
continue;
}

const repo = await resolveRepo(accountConfig, transcript, conversation);
if (!repo) continue;

Expand Down Expand Up @@ -175,7 +189,7 @@ async function main() {
if (GITHUB_STEP_SUMMARY) {
await writeFile(
GITHUB_STEP_SUMMARY,
`### Crisp triage — active-conversation dedupe check\n\nChecked: ${checked} · Matched: ${matched}\n`,
`### Crisp triage — active-conversation dedupe check\n\nChecked: ${checked} · Matched: ${matched} · Skipped (no customer message in ${DEDUPE_MAX_QUIET_DAYS}d): ${skippedQuiet}\n`,
{ flag: "a" }
);
}
Expand Down
Loading