Skip to content

Add Contact Center module - #501

Open
MikeAlhayek wants to merge 635 commits into
mainfrom
ma/add-contact-center
Open

MikeAlhayek wants to merge 635 commits into
mainfrom
ma/add-contact-center

Conversation

@MikeAlhayek

Copy link
Copy Markdown
Member

No description provided.

Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs Outdated
Comment thread src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs Outdated
Comment thread src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs Outdated
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@MikeAlhayek

Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved in 7ab102b.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

MikeAlhayek and others added 12 commits September 13, 2026 22:00
A Web Audio source node binds to the track it was built from and does not follow
a track swapped into the same stream. So after a mid-call microphone or boost
change the probes went on measuring the old, now stopped, track and reported
silence for the rest of the call: on a live call the encoder's own measurement
read full scale while the probe beside it read 0.000. Telemetry that contradicts
itself is worse than none, because it invites chasing the wrong half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Half of the silence an agent waits through between finishing a sentence and
hearing the reply is spent inside their own browser. Chrome holds arriving audio
before playing it, sized for the jitter it expects rather than the jitter it is
seeing: on these calls the network jitter measured 3-13 ms while the buffer held
60-105 ms, and it opened at 310-628 ms for the first half-minute of a call,
which is why the beginning of a call feels worst. All of it sits in the return
leg of a conversation.

An Audio delay setting asks the browser for a shorter hold, applied to the call
already in progress. It is a hint, not a command: the browser still grows the
buffer when packets really do arrive late, so this trades a smaller safety
margin rather than forcing anything, and Automatic -- no hint at all, exactly as
before -- remains the default. The shortest offered hold is deliberately not
zero, because a buffer of nothing conceals on the first packet that is a
millisecond late and sounds worse than the delay it removes.

What that costs is now measured beside it. Concealment -- the share of received
audio the browser had to invent because its packet arrived too late to play --
is reported per sample, to the diagnostics readout and the server log. A shorter
buffer is an improvement only while concealment stays near zero, and the two
numbers have to be read together; the payload contract test caught the field
before it could reach a report that had nowhere to bind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Most of what this cost was learning what to measure: six confident explanations
were each overturned by a measurement, and the instrumentation is the part worth
keeping. The page records how to read a quality sample when a caller complains,
the defects that reading it exposed, the settings an agent can change on a live
call, and the limits no setting removes -- the G.711 bandwidth to a PSTN caller,
and the delay a browser bridged to the telephone network will always carry.

It ends with the caution the investigation earned: recorded prompts reach the
same caller over the same leg sounding clear, which refuted the conclusion that
bandwidth explained "muffled". When a structural limit and a fixable one both
explain a symptom, prefer the one that can be measured, and measure it before
deciding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The provider decides which of its points of presence a browser connects to by
looking up where that browser appears to be, and says in its own documentation
that the answer can be wrong -- their example is a client in India routed to
Frankfurt rather than Chennai, with the call latency that implies. An agent
inherited whatever that lookup returned, nothing in the soft phone ever said
which location they had landed on, and there was no way to move.

A tenant can now pin the location, and an agent can pick a nearer one for
themselves under the gear icon. The agent's choice has to be able to win: the
right edge follows where the person is sitting, so a team split across two
continents has no single right answer, and the tenant setting is a starting
point for everyone rather than a rule over anyone. Automatic falls through to
the tenant, and then to the provider's routing, so nothing changes for anyone
who sets nothing.

A location the provider does not recognize is dropped rather than passed on, on
both sides of the wire. The SDK turns the value into a signaling hostname, so an
unrecognized one resolves to nothing and the agent cannot register at all --
worse than the routing the setting exists to correct.

Unlike every other audio setting this one is fixed when the provider client is
built, so changing it rebuilds the registration. That runs through the path that
already revokes the credential it replaces, because an agent may only hold so
many before the provider refuses the login; under a live call it waits, since
rebuilding would drop the call.

Scope, because it is easy to assume more: this moves the SIGNALING edge. The
provider documents signaling and media as separate planes and does not say how a
browser leg's media gateway is chosen, so whether media follows is a question
for the round-trip figure on a call to answer, not for this commit to claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An automated call is classified by a model reading its transcript, and the
choice it makes is not a label: a disposition is what the subject's workflow
acts on next. The rule was already right -- offer the dispositions the subject's
own actions are wired to, fall back to the whole catalog for a subject nobody
has wired up yet, and refuse a value that was not on offer -- but it lived
inline in the conclusion, where the only thing that had ever proved it was a
single live phone call.

The rules move to the conclusion policy beside the ones about silent calls and
escalated ones, which is where a reader looks for what a finished call may claim
happened on it, and where they can be exercised without a provider account.

Nine cases cover them, including the two that matter most and were previously
unproven: a disposition the model invented is discarded, and so is one that
exists in the catalog but belongs to a different subject -- a call concluded
that way would put an outcome on the record that nothing downstream can act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things a live automated call could not do, both for the same reason.

A realtime call is a speech-to-speech session: the model is speaking, not
returning text, so the hangup marker the turn-based loop writes into its reply
has nowhere to go. The assistant said goodbye and then held the line until the
customer worked out that nobody was going to hang up. Observed on a live call --
the closing line, and then an open line until the customer disconnected.

The model now has a tool to end the call with, and the session does the hanging
up. Three things have to be true first, and each is somebody's experience of the
call: the model has decided it is over, its closing line has actually finished
playing, and the customer has had a breath to answer it. If they use that breath
-- "actually, one more thing", which is when people say it -- the hangup is
abandoned rather than deferred, and the assistant answers them. A customer who
is still talking has not finished the call, whatever the model concluded.

The grace runs from the end of the goodbye rather than from the tool call, so a
long closing line does not eat the window the customer was supposed to get; and
the session waits for the goodbye to START, because the tool lands first and the
silence before the first word is not the end of anything.

Escalation was broken by the same gap, silently. A realtime session is
configured once, from the profile, and never sees the per-turn tool wiring the
turn-based path does -- so the transfer tool was never attached and the guidance
never reached the model. The loop read a handoff flag that nothing on a realtime
call could set: a caller asking for a person was talked to by the assistant
instead. Both tools now go in where a realtime session is built, and the
transfer tool only where the call actually has a queue behind it, because
offering it otherwise promises a person who is not coming.

Checked against the unfixed code first: the closing tests fail there. One hung
rather than failing, which is why they are bounded -- a regression should fail,
not stall the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A caller asked for a person. The model said "connecting you with an agent now",
the tool recorded the transfer, the assistant went quiet -- and nobody was ever
enqueued. The caller sat on an open line in silence, with no hold music, because
hold music belongs to a queue they were never put in. Not one line was logged.

A realtime session holds the caller for the whole call, and it does so inside
the provider's "call answered" webhook request. No provider waits minutes for a
webhook response: ours timed out after seconds, retried the webhook twice, and
redelivered it again seven minutes later. When the session finally ended, the
application aborted a connection that had been dead for a minute and a half, and
the handoff that came next went with it -- along with the request's cancellation
token, already cancelled, which would have failed the enqueue before it could
log anything anyway.

So the call is now finished somewhere else: a child scope, with its own
services, its own clock on the work, and a cancellation token of its own. What
the session decided travels as plain values, because nothing from the dying
scope can be carried across it.

The tests that let this through were the interesting part. One asserted that the
loop enqueues when the handoff flag is set, and stayed green throughout -- while
the live path could neither set that flag nor survive long enough to act on it.
It tested the branch and not the journey. The replacements cross the seam: one
proves the finishing work is handed on when the request is already aborted, the
other proves that work enqueues the caller with a token nobody has cancelled.
Both fail against the previous shape.

The loop passed the file-size line on the way, so the handoff and the ending of
a call now live in a partial of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transfer still did not reach an agent. The tool recorded it, the assistant
went quiet, the connection was aborted four seconds later, and the queue stayed
empty -- with nothing logged between the tool and the abort, which is what made
the last two attempts guesswork.

The child scope was still the request's child, so it died with the request. The
scope now comes from the shell host, which belongs to the tenant rather than to
any request, and the work is not awaited by the handler that started it. That is
the shape the SMS webhook endpoint already uses, for the same reason written on
it there: a provider gives a webhook seconds, and a voice session holds one for
the length of a call.

Silence is no longer a possible outcome. The session ending, the decision it
reached, the attempt to act on it and the work itself each say so, so if this
still does not reach an agent the log will say where it stopped rather than
leaving it to be inferred from a Kestrel connection message.

Also, from the same call: the workspace listed +17024445506 where a person
expects (702) 444-5506, and never showed how long a call lasted. The formatter
was there but only in the soft phone's bundle, and the workspace has no soft
phone on the page -- so it silently fell back to raw digits. It is shared with
the telephony client now, with a bundle test holding it there, and history shows
talk time from answer to end. A call that was never answered shows none, because
"0:00" reads as a call that connected and said nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…never comes

The closing watchdog waited on the end-call signal and nothing else:

    await Task.Delay(Timeout.InfiniteTimeSpan, endCallRequested);

On a transfer that signal never arrives. The model asked for a person, not for
the call to end -- so the watchdog never finished, and the teardown waits for
every pump before the session returns. The session therefore never returned, and
everything written after it was unreachable: the handoff, the enqueue, the hold
music, the offer. A caller was told a person was coming and then left on a line
nobody could be seated on, and the log fell silent at exactly the moment the
work should have started, because the code that logs it was never reached.

The same was true of the commonest ending of all -- the caller simply hanging up
-- which is why calls that ended that way wrote no outcome either.

The watchdog now watches the call scope alongside the signal, so it ends when
the call does, whatever ended it.

Three live tests were spent looking for this in the wrong place. Each time the
absence of a log line was read as "the work ran and failed quietly" when it
meant "the work never started"; the instrumentation added along the way is what
finally made the difference between those two readable.

Every existing test passed straight through this defect, because none of them
supplied an end-call token at all -- with no token the watchdog never starts.
The two added here do, one for a transferred call and one for a caller who hung
up, and both hang and then fail against the old wait.

Verified live end to end: transfer recorded, session ended, caller enqueued and
reserved, hold music played, offer accepted, both legs bridged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting a voicemail answered 404, and so did playing one -- the same 404, from
the same cause, which is why the inbox looked fine and neither button did
anything.

The soft phone addresses a voicemail by the identifier on its own inbox row.
That is the platform interaction's identifier only when the platform projected
the row; a call the soft phone sent to voicemail itself carries a generated one.
Both endpoints looked it up as an interaction identifier, found nothing, and
said "not found" about a recording sitting in plain sight in the agent's inbox.
The database says it plainly: for the voicemail on this system, a lookup by
interaction identifier matches nothing and a lookup by the provider call it was
recorded on matches exactly one.

The inbox row knows that call, so the endpoints fall back to it -- reading the
row as the signed-in user's own, so an identifier belonging to somebody else
resolves to nothing rather than to their voicemail.

Two things found while fixing it. The playback audit and the deletion erase were
both keyed to whatever identifier the soft phone asked with, so on these rows an
audit trail would have been written against something that is not the recording
it is about; both now use the interaction that was resolved. And a test written
for the fallback failed until that was corrected, which is how it surfaced.

Also included, from the live transfer testing: the queue work that makes an
offered caller hear something (an offered caller is Reserved, and treatment
passes read Waiting only, so no pass would ever play to them), and the handoff
work that goes with it. Verified live end to end -- hold music played, the offer
was accepted, both legs bridged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main carries the platform from preview.247 to preview.270, which drops
AIDeploymentPurpose and, with it, the profile's own realtime deployment.
Realtime is a capability a model either has or does not now, so the question
this branch asked -- "was a second deployment configured" -- has no answer any
more. It asks the one that replaced it: can the deployment this profile already
chats on hold a live conversation.

Four conflicts. Two were using blocks and took main's side. The changelog keeps
both entries. The SMS handler had diverged furthest -- this branch has the
handoff, the conversation gate, the lock and the humanized pacing on it -- so it
keeps this branch's version with main's slot resolution applied, rather than
either side wholesale.

The version bump then reaches past the conflicts: five deployment resolutions
move to the slot API, the live-session decision moves to the capability service,
and the deployment a session is held on travels on the call context instead of
being read off the profile.

The whole suite passes, and the tests that cover the agent handoff were written
against live failures rather than around them. The live-session decision itself
is the one thing worth a phone before it is trusted: it is new code on the exact
path four live calls were spent on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four live calls went into one agent handoff, and each time the suite looked
healthy because every test stopped at a seam. That is worth writing down once,
next to the list of what each remaining live test is actually for -- so a call is
spent on a question only a call can answer.

The plan states the rule the last two days earned: a defect found live gets a
test that fails against the code that produced it, before the fix is committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MikeAlhayek and others added 12 commits September 15, 2026 13:50
The call opened "Hey, is this Marcus?" to a contact named Amani. Amani asked who
it was looking for -- the only sensible reply -- and the assistant read that as a
request for a human and transferred the call. The conversation was over before it
began, and the compliance behaviour it was placed to test never came up.

The turn-based greeting is rendered from a template with the contact in scope, so
it opens with the real name. A live session is configured from the profile and
never sees that template, so the model was told nothing about who answered. Asked
to open a sales call with no name, a model does not decline to use one: it
invents a plausible one, and the person who picks up knows at once that nobody
actually knows them.

The session is now told who it is calling, and told that it is the only name it
may use. When there is no name, it is told to use none -- silence on the subject
is what produced the invented one.

The transfer guidance is tightened for the same call: a question is not a request
for a person. "Who is this?", "who are you looking for?" and their like are
asking the assistant to explain itself, and when it is unsure it should ask
whether to put them through rather than transferring on a guess.

Also completes the merge migration: the deployment a live session is held on is
now actually set on the call context. The edit that was meant to set it silently
matched nothing after the file moved, and the session only worked because the
orchestrator falls back to a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ho hangs up

Three things this call exposed, and one that it could not.

A caller asked not to be called and was put through to a queue instead. Speech
recognition on a phone line drops small words: "don't call me" reached the model
as "call me", which reads as a request to be connected and was acted on as one.
The negation cannot be recovered after the fact, so the session is now told how
this arrives -- that a short or garbled phrase around a refusal is an opt-out
unless the customer clearly says otherwise, that an opt-out ends the call, and
that somebody trying to end contact is never transferred. When it genuinely
cannot tell, it asks rather than assuming the reading that keeps somebody on the
list.

The disposition for that call came back as an ordinary completed call. Choosing
one is the most consequential thing the review does, because each disposition is
wired to what happens to the customer next, and an opt-out recorded as a
completed call is the single mistake here that cannot be undone later. The
review is told so, and told to read every disposition it is given rather than
the one that sounds positive.

A caller who hung up while waiting for an agent stayed in the queue. The leg
they were on belongs to the automated voice module, which deliberately keeps its
events away from Contact Center routing -- right until the handover, and wrong
immediately after it. The queue now hears about it through an abstraction the
voice module can see, resolved optionally so automated voice still runs on a
tenant with no Contact Center. Ending the interaction is what does the work: the
reconciliation that cancels reservations and clears the queue only acts on an
ended interaction, and the abandonment figure counts calls that ended without
being answered -- so the number meant to show callers giving up was reading zero
while exactly that happened.

The conclusion prompt moves to Templates/Prompts as a file, like every other
system prompt here, so it can be read and changed without editing C#.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g up

A profile whose model declares the realtime capability was still running the
turn-based speak-and-transcribe loop. The deployment was resolved through the
chat slot, and the framework excludes realtime from that slot on purpose -- a
speech-to-speech model cannot serve a text completion -- so the lookup answered
"no such deployment" for precisely the deployment being looked for. Nothing
failed: the call connected, the assistant spoke, and the only symptom was on
the phone, where it could not hear the caller while it was talking. It is now
asked of the deployment catalog by capability, which is how the rest of the
platform asks the same question, and a call that does fall back says so in the
log rather than leaving the difference audible but untraced.

The tests did not catch it because they set the chat slot up to hand back a
realtime deployment, which the framework will not do. The harness now models
the real behaviour, and eleven realtime tests fail against the old resolution.

That fallback also had no way to end a call: the end-call tool reached realtime
sessions only, so on a turn-based call the assistant said goodbye and then sat
there until the customer hung up. It is now offered on every turn, alongside
the transfer tool when the call has somewhere to transfer to, and the closing
line is spoken in full before the line is cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll to hang up

Resolving the realtime deployment correctly got the session as far as being
started, where the orchestrator refused it: a session carrying tools needs an
active AIInvocationScope, because that is where a tool reads its context from.
The runner never opened one, so the refusal threw, the runner reported that it
had held no session, and the call fell back to the turn-based loop -- the same
symptom as before, one layer further in. The scope is now opened before the
session and held for the whole call.

The fake orchestrator in the tests accepted a session without one, which is why
nothing failed here. It now refuses to answer that question falsely: it records
what scope was current, and the test fails against the unfixed runner.

Separately, only realtime sessions were ever told that hanging up is their job.
A turn-based call had the tool attached and no idea it was expected to use it,
so it said goodbye and waited for the customer to work out that nobody was
going to hang up. That guidance now lives in one place and both paths give it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An automated call or message chooses its disposition from a list of names and
descriptions, so that description is the whole of what the model has to go on.
Until now it could only be the disposition's general description, and the same
disposition legitimately means different things on different work: "Done" on a
sales follow-up is not "Done" on a service call.

A subject action can now carry its own wording, which replaces the general
description for that subject's conversations. The editor starts the field off
as a copy of the disposition's description when one is chosen, so writing the
specific version is an edit rather than a blank page, and it only ever replaces
text the author has not written themselves. Left empty, the general description
is used exactly as before.

Both channels ask the same question, so the rule that decides which description
the model sees is written once and used by the voice conclusion and the SMS
conclusion alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A disposition can set a contact's do-not-call, do-not-sms, do-not-email or
do-not-chat preference. It was applied to the content item in memory and then
dropped: no caller saved the contact, and the executor did not either. So a
customer could ask not to be called, be dispositioned exactly right by the
model, and be dialled again on the next inventory load -- which is what was
happening on a contact that had been asked and recorded three times.

The change and the write now live together, and the published version is
updated when there is one, because the lists that decide who gets dialled or
messaged query published contacts. A disposition that says nothing about
contact preferences still writes nothing.

The existing test asserted the preference on the in-memory item and stopped
there, which is exactly the half of the behaviour that worked. It now asserts
the save as well, and fails against the unfixed executor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Realtime voice and the assistant ending its own call are now proven on a live
call rather than only by test. The compliance entry is narrowed to the half
that never worked, and two new entries name what is held by test and has not
yet been judged by a model on a phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The window the caller gets after the assistant's goodbye was ended only by a
transcript, and a transcript exists only once they have stopped talking and the
provider has transcribed them -- seconds after they opened their mouth. So the
person who said "actually, one more thing" was cut off mid-word by the very
guard meant to protect them, and the test covering it passed because it queued
a transcript rather than the speech that produces one.

Voice detection fires on the first syllable, and that event was not handled at
all. It now stamps the same clock, so the call stops being over the moment the
caller decides it is not.

The window itself goes from two seconds to four. Two was about the length of a
breath and read on a real call as being hung up on; the cost of the extra
couple of seconds is a little silence at the end of a call that was over anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things heard on a live call, both in the live session.

The assistant waited to be spoken to. A turn begins with voice detection, and
nothing ever asked the model to speak first, so every transcript opened with the
customer saying "Hello?" into dead air on a call we placed. The session now asks
for a response as soon as it is configured.

And it said its goodbye twice, word for word. The model says the closing line,
calls the end-call tool, reads the tool's reply and -- with the line still open
while the customer is given their moment to answer -- produces the same line
again. Nothing here can stop it being generated; what it can do is not play it,
not count it towards the closing silence, and not write it to a transcript that
is supposed to say what was said on the call. The customer speaking clears that,
because then the call is not over after all.

The pumps move to their own file: they are the part of the session that runs for
the whole call, and the file had grown past the size the architecture guard
allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous attempt read the end-call request at the moment a line finished,
which assumed the request arrives first. It does not: the model says its closing
line and calls the tool after it, so the flag was set one line too late and the
repeat was still played and still written to the transcript -- heard again on a
live call.

The request is now watched rather than read. The line in flight when it arrives
is the goodbye and finishes; anything the model starts afterwards is the repeat.
A request that arrives while nothing is being said means the goodbye is already
behind us, so the next line is a repeat too.

The test cancelled the token before queueing the goodbye, which is the easy
ordering and the one that hid this. It now queues the line first and the request
after, as a call does, and fails against the previous attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every conversation-level fault found on this platform so far -- a transfer
nobody asked for, a goodbye said twice, a reply the model never heard -- was
diagnosed from the stored transcript after the call, because the turns
themselves left no trace at all. The realtime session logged four things, none
of them a turn.

Turns are now recorded at debug, and the two ways a caller can be lost are
recorded at information, because neither is a detail: a transcript that comes
back empty, and an utterance the provider could not transcribe. The second was
not even handled -- it fell through the switch in silence, and it is the most
likely explanation for an assistant that ignored a short "yes" and waited for it
to be said again.

Also records, as a test, that a session which answers by itself cannot be asked
to open the call. The fake counted the request whatever kind of session it was,
which is why asking it to speak first passed here while every live call still
opened with the customer saying "Hello?" into silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things a person on the phone noticed, and one they could not.

The assistant never opened a call it had placed: a turn begins with voice
detection, so the customer had to say "hello?" into silence before it would
speak. It could not be fixed from here -- a session the provider answers for
itself refused every way of creating a response -- so it was fixed in the
framework and is used here now that it has shipped (CrestApps.Core #198,
2.0.0-preview.277).

Nothing broke a silence either. The turn-based loop has always asked whether
somebody is still there; the live session sat waiting, which is what a caller
whose reply was lost sat through. It now asks after twelve quiet seconds, twice,
and then stops -- a caller who has gone is not brought back by a third attempt.
The wording is deliberate: asked only to check whether they were still there,
the model filled the silence by moving the sale along, replacing a question the
customer had missed with a new one. It is now told to ask that question or
repeat the last one, and nothing else.

Both clocks start at the call rather than at zero, or the first check measures a
silence stretching back to the beginning of time and speaks over the opening
line -- which the test caught before a caller did.

The one nobody could see: the turns themselves left no trace. Every
conversation-level fault found here was diagnosed from the stored transcript
afterwards. They are now logged as they happen, and the two ways a caller can be
lost -- an empty transcript, and an utterance that could not be transcribed --
are recorded plainly, the second having previously fallen through in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts. Please resolve those before requesting a review.

MikeAlhayek and others added 3 commits September 16, 2026 18:40
A batch decides who to contact when it is loaded. What it creates is placed
later -- hours later, longer if rescheduled -- and nothing looked at the contact
again in between. Somebody who asked to be left alone in that window was still
holding an activity with their number on it, and it was dialled.

The question is now asked immediately before the call or message goes out, and
the rule behind it lives in one place instead of being a few lines inside a
destination lookup in one module, which is why three other paths never asked it
at all: the SMS processor, which is the last code before the carrier and is also
reached directly by the workflow task; the cadence that revives a quiet
conversation, which would otherwise keep messaging somebody who had said stop,
on a schedule, for days; and an automated voice call, which reached the provider
without passing the boundary a person's call passes, so no national registry and
no calling window applied to it. That one now goes through the same screening an
agent's call does, as its own origin so a screener can tell the two apart.

The batch's own "include do-not-calls" flags -- one per channel -- were stored,
editable, cloned, API-baselined and read by nothing. They work now: unticked
respects the preference, which is what the form has always implied, and ticking
one is an operator deciding this particular message is not the kind a preference
is meant to stop. Excluding opted-out contacts also no longer depends on the
activity being automated; an agent's call sheet should not contain people who
asked not to be called either.

Two more defects the new tests pinned rather than papered over. The SMS
conclusion handed a possibly-null disposition to an executor that rejects null,
inside a deferred scope, after writing the outcome but before committing it --
so the conversation was left open with no disposition, no notes and no
follow-up, and the only trace was a stack in a log. And the bulk-manage
do-not-call filter compared a boolean column with the integer literal 1, which
SQLite accepts and PostgreSQL refuses outright, while its inner join multiplied
every activity by the number of index rows behind its contact -- the count shown
before a bulk complete or purge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A contact could tick "do not chat", and it was stored, projected into an index,
importable, exportable, settable by a subject action and shown in the editor --
and read by nothing. There is no chat channel on this platform: no constant, no
processor, no path that creates chat work. Somebody who asked not to be chatted
with was being told something the product had no way to keep, which is worse
than not offering the choice.

Its three siblings stay. Do-not-call and do-not-sms are enforced at four points
each, and do-not-email gates the creation of email activities a person then
works by hand -- a thin channel, but a real one.

The recorded values are not destroyed: they stay verbatim in each contact's
document and are ignored on deserialize, so nothing is unrecoverable if a chat
channel ever arrives. Only the projection goes, and a projection is rebuildable.
Both column drops are registered in the migration contract with the reason,
rather than the guard being loosened to let them through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite was green all day on a Debug build, and the pipeline does not build
that way: it builds Release with warnings as errors and analyzers on, and under
those switches nine rules failed. Four were mine from today, four predate today
and had been sitting as warnings nobody had to look at, and one came in with the
new tests.

A cancellation token that was not the last parameter; a log argument sanitized
on the way into a call that may discard it, on a path that runs for every event
of a live call; an Assert.Single filtered by a Where clause instead of the
overload that takes the predicate; a test helper whose dialect parameter was an
interface every caller resolved to the same concrete type, now stated explicitly
by each caller because some of those statements genuinely differ between SQLite
and PostgreSQL; and six awaits in tests that did not carry the test's own token.

Verified the whole way through: the solution builds clean under the pipeline's
own switches, and both suites the pipeline runs pass -- 5,294 and 76.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants