enhancement
major
minor
defect
critical
major
minor
major
#30164
TL Chat: A run that fails with an exception leaves no error in the conversation; it is gone after a reload
Problem
A run whose ConversationBackend or agent loop throws ends in RunRecording.fail (BackendRun.run, AgentRunner). RunRecording.fail goes through ExecutionRecorder.completeWithLatestTranscript, which records nothing while no RunStarted was seen (the execution row is created only on RunStarted), and nothing writes a Part.ErrorPart: only ConversationLogListener.handleProviderError does, for an AgentEvent.ProviderError. ChatProcessor.AIBackgroundTask.notifyError sends the provider_error frame to the live page only.
So the live chat shows the error card (and the composer strip, deliberately, see the card design of chat-store.ts, "recorded exactly as a re-opened session records it"), but after a reload the user's question stands without answer and without error. After a server restart the question is gone too.
Seen in tl-factory (Gitea TopLogic/tl-factory #38): the backend of a workspace's coding agent refuses a run for a stopped workspace with a TopLogicException before RunStarted.
Change
RunRecording.fail records a failure like a provider error: it opens the execution row if none exists and adds an ErrorPart to the run's open turn before taking the transcript. The part's kind is that of a ProviderException, else APPLICATION_ERROR (moved from ChatProcessor to tl-ai-core), its message the I18NFailure text in the language of the run's user. The live frame stays, so the live and the reloaded view match.
Test
A ConversationBackend throwing a TopLogicException before RunStarted: the conversation loaded through ChatHistoryService.loadConversationJson holds the user turn and an ErrorPart with kind APPLICATION_ERROR and the German message. The same for an agent loop that throws.
Lösung
- RunRecording.fail delivers the failure as an AgentEvent.ProviderError through the same listener chain as every other event of the run (no separate write into the turn): kind of a ProviderException, else APPLICATION_ERROR; message the I18NFailure text in the language of the run's user; messageKey the failure's key. Every listener thereby sees the failure as it sees a provider error, and the execution record and the conversation log cannot diverge. Not after a RunCompleted, and not twice for the same failure.
- The agent loop, which reports RunCompleted(ERROR) before it rethrows, delivers the same ProviderError right before that event.
- RunFailure (tl-ai-core) builds the event and holds APPLICATION_ERROR and the derivation of kind and message; ChatProcessor uses it. The wire value APPLICATION_ERROR is unchanged.
- ConversationLogListener.handleProviderError opens a turn when none is open, so the user's question is recorded with the ErrorPart.
- ExecutionRecorder creates the execution row on a failure before RunStarted (agent, session, parent), so the transcript with question and error is stored.
- The live page gets one error card: when the ProviderError passed the run's listener, notifyError sends no second provider_error frame.
- A refusal by the run limit (RunLimitExceededException, budget or concurrency before RunStarted) is not recorded as a failure: the chat answers it with the reason as the agent's text, as before.
- Applies to every caller of RunRecording.fail (AgentRunner, AIScriptFunctions, DelegationTool); a subagent whose loop throws shows the error on its card.