Skip to content

fix(chat): end a turn when the agent stops speaking, not once it has spoken - #4

Merged
a7vinx merged 1 commit into
mainfrom
feat/turn-timeout
Aug 12, 2026
Merged

fix(chat): end a turn when the agent stops speaking, not once it has spoken#4
a7vinx merged 1 commit into
mainfrom
feat/turn-timeout

Conversation

@a7vinx

@a7vinx a7vinx commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

A turn ended two seconds after silence, measured from the first reply of any kind and never re-measured. The check is now asked of every event: was the last thing to arrive the agent speaking?

This also adds turn_timeout, a wall-clock bound on one turn, and corrects the set of session states that end one.

The defect

received_agent_response was set by the first substantive event and never cleared. From that point the whole turn ran on the two-second timeout, including the part where the agent is working rather than talking — and silence is exactly what a running tool call looks like.

agent:  "Placing the call now."     -> flag set, wait drops to 2s
        session:tool_status          -> flag unchanged
        (call runs, 30s of silence)  -> 2s elapses, turn ends

The caller was cut off from the result of the operation it had just been told about.

The rule

Last event Wait
session:text, session:text_part, session:rich_content, session:form_to_user, session:task_finished, session:restriction 2s — the agent has spoken and gone quiet
Anything else 120s — the agent is working

session:tool_status moves out of the first set for the same reason it caused the defect: it reports what the agent is doing, not what it says.

Where the rule stops

Nothing else ends a turn, so a turn whose last event is neither speech nor a settled state waits indefinitely.

Three plain turns recorded against a live server each ended on session:text, and a turn that ran a task ended on a settled state. Neither is guaranteed. turn_timeout bounds a turn in wall-clock seconds and keeps whatever arrived before the deadline:

async for event in client.chat(sid, "...", turn_timeout=120):
    ...

It defaults to None, so no existing caller changes behaviour. A test states this boundary rather than leaving it to be discovered.

The name deliberately describes the bound rather than the mechanism, so it survives a change of mechanism. The two internal timeouts stay internal.

Session states

credits_exhausted and task_paused now end a turn. Both stop on the account rather than on the agent, so nothing further arrives from the session itself.

task_stale is dropped. The server has no such state — its session states are init, chat, task_ready, task_processing, task_interrupted (deprecated), task_finished, task_cancelled, task_paused and credits_exhausted. Staleness is is_stale on the session object over REST; the two were conflated.

Tests

The flow tests closed a turn by sending session:input_state, which left the supported scope in #3 and no longer ends anything. They now close on a settled session:state — explicit rather than timing-based, and no longer dependent on an unsupported event.

Four tests cover the new rule: a turn that ends on speech, a running tool that is not mistaken for a finished turn, a session stopped on its balance, and the unbounded case with and without turn_timeout.

Verification

  • 90 offline tests pass; ruff check clean; python -m build and twine check pass.
  • 7 live tests pass against a local backend. The outbound-call test was deselected: it places a real call and spends credits.
  • The claim that a plain turn ends on session:text comes from three recorded local sessions, not from reading the server.

🤖 Generated with Claude Code

…spoken

A turn ended two seconds after silence, measured from the first reply of any
kind and never re-measured. One flag was set on the first substantive event and
never cleared, so everything after it ran on the two-second timeout — including
a tool call, where silence is what a running call looks like. An agent that
said "placing the call now" and then placed it was cut off from its own result.

The flag is replaced by a question asked of every event: was the last thing to
arrive the agent speaking? Text, a document, a form, a result, a restriction —
those start the two-second clock. Anything else clears it and the wait stays
long. `session:tool_status` moves out of that set for the same reason it caused
the bug: it reports what the agent is doing, not what it says.

## Where the rule stops

Nothing else ends a turn, so a turn whose last event is neither speech nor a
settled state waits indefinitely. Three plain turns recorded against a live
server each ended on `session:text`, and a turn that ran a task ended on a
settled state, but nothing guarantees either. `turn_timeout` bounds a turn in
wall-clock seconds and keeps whatever arrived before the deadline; it defaults
to none, so no existing caller changes behaviour. A test states the boundary
rather than leaving it to be discovered.

## Session states

`credits_exhausted` and `task_paused` now end a turn. Both stop on the account
rather than on the agent, so nothing further arrives from the session itself.

`task_stale` is dropped: the server has no such state. Staleness is `is_stale`
on the session object, read over REST — the two were conflated.

## Tests

The flow tests closed a turn by sending `session:input_state`, which left the
supported scope in the previous change and no longer ends anything. They now
close on a settled `session:state`, which is explicit rather than timing-based.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@a7vinx
a7vinx merged commit 480d943 into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant