mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
Merge pull request #284 from leookun/fix/compress
Enhance checkpoint handling and error management in forwarder
This commit is contained in:
@@ -730,6 +730,9 @@ func (service *Service) handleProviderDoneEvent(stream *ActiveStream, payload *s
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
return service.closeStreamWithProviderError(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, usage, providerErr, !hadToolInvocation)
|
||||
}
|
||||
if err := service.flushAssistantText(stream, conversationID, turnSeq, requestID, accumulatedText, accumulatedReasoning, accumulatedReasoningSignature, accumulatedReasoningSignatureSource, accumulatedReasoningItemID, accumulatedReasoningStatus, accumulatedReasoningSummary, !hadToolInvocation); err != nil {
|
||||
return service.failStream(stream, "unknown", fmt.Errorf("flush failed provider output: %w", err))
|
||||
}
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
return service.failStream(stream, "unknown", payload.Err)
|
||||
}
|
||||
|
||||
@@ -19,15 +19,29 @@ type pendingCheckpointBlobWrite struct {
|
||||
blob CheckpointBlob
|
||||
}
|
||||
|
||||
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
|
||||
func successfulCheckpointTerminalAction(completion *pendingTurnCompletion) checkpointTerminalAction {
|
||||
if completion == nil {
|
||||
return nil
|
||||
return checkpointTerminalAction{}
|
||||
}
|
||||
return checkpointTerminalAction{
|
||||
Kind: checkpointTerminalActionComplete,
|
||||
Completion: *completion,
|
||||
}
|
||||
}
|
||||
|
||||
func failedCheckpointTerminalAction(errorCode string, errorMessage string) checkpointTerminalAction {
|
||||
return checkpointTerminalAction{
|
||||
Kind: checkpointTerminalActionFail,
|
||||
ErrorCode: strings.TrimSpace(errorCode),
|
||||
ErrorMessage: strings.TrimSpace(errorMessage),
|
||||
}
|
||||
cloned := *completion
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
|
||||
return service.queueCheckpointProjectionWithTerminal(stream, projection, successfulCheckpointTerminalAction(completion))
|
||||
}
|
||||
|
||||
func (service *Service) queueCheckpointProjectionWithTerminal(stream *ActiveStream, projection *CheckpointProjection, terminal checkpointTerminalAction) error {
|
||||
if service == nil || stream == nil || projection == nil || projection.State == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -43,8 +57,8 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
|
||||
if stream.ConfirmedCheckpointBlobs == nil {
|
||||
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
|
||||
}
|
||||
if completion == nil && stream.PendingCheckpoint != nil {
|
||||
completion = stream.PendingCheckpoint.Completion
|
||||
if terminal.Kind == checkpointTerminalActionNone && stream.PendingCheckpoint != nil {
|
||||
terminal = stream.PendingCheckpoint.Terminal
|
||||
}
|
||||
required := make(map[string]struct{}, len(projection.Blobs))
|
||||
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
|
||||
@@ -74,11 +88,11 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
|
||||
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
|
||||
}
|
||||
stream.PendingCheckpoint = &pendingCheckpointPublish{
|
||||
State: state,
|
||||
Required: required,
|
||||
Completion: clonePendingTurnCompletion(completion),
|
||||
State: state,
|
||||
Required: required,
|
||||
Terminal: terminal,
|
||||
}
|
||||
if completion != nil {
|
||||
if terminal.Kind != checkpointTerminalActionNone {
|
||||
stream.Phase = TurnPhaseCheckpointing
|
||||
}
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
@@ -94,13 +108,8 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
|
||||
if service.checkpointProjectionReady(stream) {
|
||||
return service.publishReadyCheckpoint(stream)
|
||||
}
|
||||
// Keep the latest live UI state ahead of an immediate client abort. Blob writes are
|
||||
// ordered before this snapshot; acknowledgements still gate terminal completion.
|
||||
if completion == nil {
|
||||
if err := service.publishPendingCheckpoint(stream); err != nil {
|
||||
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish pending checkpoint: %w", err))
|
||||
}
|
||||
}
|
||||
// Checkpoints reference these Blob IDs, so the client must confirm every
|
||||
// required Blob before the checkpoint becomes visible.
|
||||
service.scheduleStreamTimer(
|
||||
stream,
|
||||
providerTimerKey(streamTimerCheckpointBlobs, ""),
|
||||
@@ -113,31 +122,6 @@ func (service *Service) queueCheckpointProjection(stream *ActiveStream, projecti
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) publishPendingCheckpoint(stream *ActiveStream) error {
|
||||
if service == nil || stream == nil {
|
||||
return nil
|
||||
}
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
if pending == nil || pending.Published {
|
||||
stream.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
pending.Published = true
|
||||
state := pending.State
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
stream.mu.Lock()
|
||||
if stream.PendingCheckpoint == pending {
|
||||
pending.Published = false
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
|
||||
if stream == nil {
|
||||
return false
|
||||
@@ -207,24 +191,18 @@ func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
|
||||
}
|
||||
stream.PendingCheckpoint = nil
|
||||
state := pending.State
|
||||
completion := clonePendingTurnCompletion(pending.Completion)
|
||||
published := pending.Published
|
||||
terminal := pending.Terminal
|
||||
stream.UpdatedAt = time.Now().UTC()
|
||||
stream.mu.Unlock()
|
||||
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
|
||||
if !published {
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
if completion != nil {
|
||||
log.Printf("forwarder checkpoint publish skipped before successful terminal request_id=%s err=%v", stream.RequestID, err)
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return err
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{Message: buildCheckpointMessage(state)}); err != nil {
|
||||
if terminal.Kind != checkpointTerminalActionNone {
|
||||
log.Printf("forwarder checkpoint publish skipped before terminal request_id=%s err=%v", stream.RequestID, err)
|
||||
return service.finishCheckpointTerminalAction(stream, terminal)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
|
||||
}
|
||||
return nil
|
||||
return service.finishCheckpointTerminalAction(stream, terminal)
|
||||
}
|
||||
|
||||
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
|
||||
@@ -251,12 +229,23 @@ func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, c
|
||||
if cause != nil {
|
||||
log.Printf("forwarder checkpoint blob sync skipped request_id=%s conversation_id=%s err=%v", stream.RequestID, stream.ConversationID, cause)
|
||||
}
|
||||
if pending != nil && pending.Completion != nil {
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, *pending.Completion)
|
||||
if pending != nil {
|
||||
return service.finishCheckpointTerminalAction(stream, pending.Terminal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) finishCheckpointTerminalAction(stream *ActiveStream, terminal checkpointTerminalAction) error {
|
||||
switch terminal.Kind {
|
||||
case checkpointTerminalActionComplete:
|
||||
return service.finishSuccessfulTurnAfterCheckpoint(stream, terminal.Completion)
|
||||
case checkpointTerminalActionFail:
|
||||
return service.finishFailedTurnAfterCheckpoint(stream, terminal.ErrorCode, terminal.ErrorMessage)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
|
||||
if stream == nil {
|
||||
return
|
||||
|
||||
@@ -8,22 +8,43 @@ import (
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestCheckpointBlobSyncPublishesNonTerminalCheckpointBeforeAcknowledgements(t *testing.T) {
|
||||
func TestCheckpointBlobSyncWaitsForAcknowledgementsBeforePublishingNonTerminalCheckpoint(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
}
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
if len(events) != len(projection.Blobs)+1 {
|
||||
t.Fatalf("events before ACK = %d, want %d Blob writes and one checkpoint", len(events), len(projection.Blobs))
|
||||
if len(events) != len(projection.Blobs) {
|
||||
t.Fatalf("events before ACK = %d, want %d Blob writes", len(events), len(projection.Blobs))
|
||||
}
|
||||
for _, event := range events[:len(projection.Blobs)] {
|
||||
for _, event := range events {
|
||||
if event.Message.GetKvServerMessage().GetSetBlobArgs() == nil {
|
||||
t.Fatalf("event before ACK = %#v, want set_blob_args", event.Message)
|
||||
}
|
||||
}
|
||||
if checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate(); checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
|
||||
t.Fatalf("last event before ACK = %#v, want one Blob-backed turn", events[len(events)-1].Message)
|
||||
|
||||
stream.mu.Lock()
|
||||
var firstRequestID uint32
|
||||
for requestID := range stream.PendingCheckpointBlobWrites {
|
||||
firstRequestID = requestID
|
||||
break
|
||||
}
|
||||
stream.mu.Unlock()
|
||||
if firstRequestID == 0 {
|
||||
t.Fatal("checkpoint projection has no pending Blob writes")
|
||||
}
|
||||
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
|
||||
Id: firstRequestID,
|
||||
Message: &agentv1.KvClientMessage_SetBlobResult{
|
||||
SetBlobResult: &agentv1.SetBlobResult{},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("first Blob ACK error = %v", err)
|
||||
}
|
||||
for _, event := range readCheckpointTestEvents(t, service, stream) {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil {
|
||||
t.Fatal("checkpoint published after only a partial Blob acknowledgement")
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
@@ -98,7 +119,123 @@ func TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
|
||||
func TestCheckpointBlobSyncPublishesCheckpointBeforeFailedTerminal(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
if err := service.failActiveStream(
|
||||
stream,
|
||||
stream.ConversationID,
|
||||
stream.RequestID,
|
||||
"model-call-1",
|
||||
"provider_error",
|
||||
"provider failed",
|
||||
); err != nil {
|
||||
t.Fatalf("failActiveStream() error = %v", err)
|
||||
}
|
||||
|
||||
for _, event := range readCheckpointTestEvents(t, service, stream) {
|
||||
if event.Message.GetConversationCheckpointUpdate() != nil || event.End {
|
||||
t.Fatalf("event before ACK = %#v, want only Blob writes", event)
|
||||
}
|
||||
}
|
||||
stream.mu.Lock()
|
||||
phaseBeforeACK := stream.Phase
|
||||
statusBeforeACK := stream.Status
|
||||
stream.mu.Unlock()
|
||||
if phaseBeforeACK != TurnPhaseCheckpointing || isTerminalStreamStatus(statusBeforeACK) {
|
||||
t.Fatalf("before ACK phase=%s status=%s, want checkpointing and non-terminal", phaseBeforeACK, statusBeforeACK)
|
||||
}
|
||||
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointIndex, endIndex := -1, -1
|
||||
for index, event := range events {
|
||||
switch {
|
||||
case event.Message.GetConversationCheckpointUpdate() != nil:
|
||||
checkpointIndex = index
|
||||
case event.End:
|
||||
endIndex = index
|
||||
if event.TerminalErrorCode != "provider_error" || event.TerminalErrorMessage != "provider failed" {
|
||||
t.Fatalf("terminal event = %#v, want provider error", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checkpointIndex < 0 || endIndex <= checkpointIndex {
|
||||
t.Fatalf("terminal order checkpoint=%d end=%d", checkpointIndex, endIndex)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
phaseAfterACK := stream.Phase
|
||||
statusAfterACK := stream.Status
|
||||
stream.mu.Unlock()
|
||||
if phaseAfterACK != TurnPhaseFailed || statusAfterACK != StreamStatusFailed {
|
||||
t.Fatalf("after ACK phase=%s status=%s, want failed", phaseAfterACK, statusAfterACK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointBlobTimeoutStillPublishesFailedTerminal(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
if err := service.failActiveStream(
|
||||
stream,
|
||||
stream.ConversationID,
|
||||
stream.RequestID,
|
||||
"model-call-1",
|
||||
"provider_error",
|
||||
"provider failed",
|
||||
); err != nil {
|
||||
t.Fatalf("failActiveStream() error = %v", err)
|
||||
}
|
||||
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
|
||||
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
|
||||
}
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
var checkpoint, failedEnd bool
|
||||
for _, event := range events {
|
||||
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
|
||||
failedEnd = failedEnd || event.End && event.TerminalErrorCode == "provider_error" && event.TerminalErrorMessage == "provider failed"
|
||||
}
|
||||
if checkpoint || !failedEnd {
|
||||
t.Fatalf("timeout events checkpoint=%v failed_end=%v", checkpoint, failedEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualCompactionNoopWaitsForCheckpointBeforeTerminal(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
if err := service.finishManualCompactionNoop(stream); err != nil {
|
||||
t.Fatalf("finishManualCompactionNoop() error = %v", err)
|
||||
}
|
||||
|
||||
for _, event := range readCheckpointTestEvents(t, service, stream) {
|
||||
if event.Message.GetInteractionUpdate().GetTurnEnded() != nil || event.End {
|
||||
t.Fatalf("terminal event before checkpoint Blob ACK = %#v", event)
|
||||
}
|
||||
}
|
||||
acknowledgeCheckpointBlobs(t, service, stream)
|
||||
|
||||
events := readCheckpointTestEvents(t, service, stream)
|
||||
checkpointIndex, turnEndedIndex, endIndex := -1, -1, -1
|
||||
for index, event := range events {
|
||||
switch {
|
||||
case event.Message.GetConversationCheckpointUpdate() != nil:
|
||||
checkpointIndex = index
|
||||
case event.Message.GetInteractionUpdate().GetTurnEnded() != nil:
|
||||
turnEndedIndex = index
|
||||
case event.End:
|
||||
endIndex = index
|
||||
}
|
||||
}
|
||||
if checkpointIndex < 0 || turnEndedIndex <= checkpointIndex || endIndex <= turnEndedIndex {
|
||||
t.Fatalf("terminal order checkpoint=%d turn_ended=%d end=%d", checkpointIndex, turnEndedIndex, endIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationDiscardsUnpublishedCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
|
||||
service, stream, projection := testCheckpointBlobProjection(t)
|
||||
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
|
||||
t.Fatalf("queueCheckpointProjection() error = %v", err)
|
||||
@@ -110,8 +247,8 @@ func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *t
|
||||
checkpointBeforeCancel++
|
||||
}
|
||||
}
|
||||
if checkpointBeforeCancel != 1 {
|
||||
t.Fatalf("checkpoints before cancel = %d, want 1", checkpointBeforeCancel)
|
||||
if checkpointBeforeCancel != 0 {
|
||||
t.Fatalf("checkpoints before cancel = %d, want 0", checkpointBeforeCancel)
|
||||
}
|
||||
stream.mu.Lock()
|
||||
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
|
||||
@@ -149,7 +286,7 @@ func TestCancellationKeepsPublishedCheckpointAndIgnoresLateAcknowledgements(t *t
|
||||
stream.mu.Lock()
|
||||
pending := stream.PendingCheckpoint
|
||||
stream.mu.Unlock()
|
||||
if checkpointCount != 1 || !canceledEnd || pending != nil {
|
||||
if checkpointCount != 0 || !canceledEnd || pending != nil {
|
||||
t.Fatalf("cancel events checkpoints=%d canceled_end=%v pending=%v", checkpointCount, canceledEnd, pending != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func (service *Service) buildLegacyCompactionPlan(base *compactionPlan, conversa
|
||||
if conversation == nil || base == nil {
|
||||
return nil, nil
|
||||
}
|
||||
candidates := buildContextCompactionCandidates(checkpointProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
|
||||
candidates := buildContextCompactionCandidates(replayablePromptProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func (service *Service) buildAutoCompactionPlanFromHistory(base *compactionPlan,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentCandidate, hasCurrentCandidate := buildCurrentTurnCompactionCandidate(checkpointProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
|
||||
currentCandidate, hasCurrentCandidate := buildCurrentTurnCompactionCandidate(replayablePromptProjectionEntries(conversation.Entries), base.CurrentTurnSeq, base.CurrentRequestID)
|
||||
if !hasCurrentCandidate {
|
||||
return legacyPlan, nil
|
||||
}
|
||||
@@ -447,16 +447,8 @@ func (service *Service) handleCompactionEvent(stream *ActiveStream, payload *str
|
||||
if err := service.completeManualCompactionTurn(stream); err != nil {
|
||||
return service.failStream(stream, "unknown", err)
|
||||
}
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||
Message: buildTurnEndedMessage(0, 0, 0, 0),
|
||||
}); err != nil {
|
||||
return service.failStream(stream, "unknown", err)
|
||||
}
|
||||
if err := service.broker.Complete(stream.RequestID, "", ""); err != nil {
|
||||
return service.failStream(stream, "unknown", err)
|
||||
}
|
||||
service.setTurnPhase(stream, TurnPhaseCompleted)
|
||||
return nil
|
||||
completion := manualCompactionTurnCompletion(stream)
|
||||
return service.publishCheckpointWithCompletion(stream.RequestID, stream.ConversationID, &completion)
|
||||
}
|
||||
return service.requestProviderAction(stream, providerActionResume)
|
||||
}
|
||||
@@ -500,12 +492,8 @@ func (service *Service) finishManualCompactionNoop(stream *ActiveStream) error {
|
||||
if err := service.completeManualCompactionTurn(stream); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.broker.Publish(stream.RequestID, StreamEvent{
|
||||
Message: buildTurnEndedMessage(0, 0, 0, 0),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return service.broker.Complete(stream.RequestID, "", "")
|
||||
completion := manualCompactionTurnCompletion(stream)
|
||||
return service.publishCheckpointWithCompletion(stream.RequestID, stream.ConversationID, &completion)
|
||||
}
|
||||
|
||||
func (service *Service) completeManualCompactionTurn(stream *ActiveStream) error {
|
||||
@@ -530,10 +518,21 @@ func (service *Service) completeManualCompactionTurn(stream *ActiveStream) error
|
||||
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil {
|
||||
return err
|
||||
}
|
||||
service.setTurnPhase(stream, TurnPhaseCompleted)
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualCompactionTurnCompletion(stream *ActiveStream) pendingTurnCompletion {
|
||||
if stream == nil {
|
||||
return pendingTurnCompletion{}
|
||||
}
|
||||
return pendingTurnCompletion{
|
||||
ConversationID: strings.TrimSpace(stream.ConversationID),
|
||||
RequestID: strings.TrimSpace(stream.RequestID),
|
||||
TurnSeq: stream.TurnSeq,
|
||||
ModelCallID: "turn:" + strings.TrimSpace(stream.RequestID),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) publishSummaryCompleted(stream *ActiveStream, hookMessage string) error {
|
||||
if service == nil || stream == nil {
|
||||
return nil
|
||||
@@ -568,6 +567,7 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
originalEntryCount := len(candidateConversation.Entries)
|
||||
if err := applyCompactionToConversation(candidateConversation, plan, summaryText); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -582,9 +582,9 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
|
||||
if validationErr := validateCompactionCandidateBudget(recompiled, plan); validationErr != nil {
|
||||
return validationErr
|
||||
}
|
||||
replacementEntries := append([]HistoryEntry(nil), candidateConversation.Entries...)
|
||||
compactionEntries := append([]HistoryEntry(nil), candidateConversation.Entries[originalEntryCount:]...)
|
||||
if service.store != nil {
|
||||
persisted, err := service.store.ReplaceEntries(conversationID, replacementEntries, func(item *ConversationFile) error {
|
||||
persisted, _, err := service.store.AppendEntriesWithUpdate(conversationID, resetEntrySequences(compactionEntries), func(item *ConversationFile) error {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -605,10 +605,7 @@ func (service *Service) applyCompactionPlan(stream *ActiveStream, conversationID
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
item.Entries = nil
|
||||
item.NextEntrySeq = 1
|
||||
item.NextTurnSeq = 1
|
||||
appendEntriesInPlace(item, resetEntrySequences(replacementEntries))
|
||||
appendEntriesInPlace(item, resetEntrySequences(compactionEntries))
|
||||
item.TokenDetailsUsedTokens = 0
|
||||
clearConversationAutoCompactionState(item)
|
||||
return nil
|
||||
@@ -643,14 +640,13 @@ func applyCompactionToConversation(conversation *ConversationFile, plan *Pending
|
||||
if conversation == nil || plan == nil {
|
||||
return nil
|
||||
}
|
||||
replacementEntries, err := buildCompactedContextEntries(conversation, plan, summaryText)
|
||||
compactionEntries, err := buildCompactedContextEntries(conversation, plan, summaryText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conversation.Entries = nil
|
||||
conversation.NextEntrySeq = 1
|
||||
conversation.NextTurnSeq = 1
|
||||
appendEntriesInPlace(conversation, resetEntrySequences(replacementEntries))
|
||||
// Canonical history stays append-only. The prompt projector applies the
|
||||
// latest summary marker when constructing model-visible replay.
|
||||
appendEntriesInPlace(conversation, resetEntrySequences(compactionEntries))
|
||||
conversation.TokenDetailsUsedTokens = 0
|
||||
clearConversationAutoCompactionState(conversation)
|
||||
if conversation.TokenDetailsMaxTokens == 0 {
|
||||
@@ -671,40 +667,9 @@ func buildCompactedContextEntries(conversation *ConversationFile, plan *PendingC
|
||||
if ok {
|
||||
entries = append(entries, runtimeEntry)
|
||||
}
|
||||
if conversation == nil || !plan.PreserveCurrentTurnInputs {
|
||||
return entries, nil
|
||||
}
|
||||
entries = append(entries, buildAutoCompactionPreservedCurrentTurnEntries(conversation.Entries, plan)...)
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func buildAutoCompactionPreservedCurrentTurnEntries(entries []HistoryEntry, plan *PendingCompaction) []HistoryEntry {
|
||||
if len(entries) == 0 || plan == nil || !plan.PreserveCurrentTurnInputs {
|
||||
return nil
|
||||
}
|
||||
latestToolCallID := latestCompletedToolCallIDForTurn(entries, plan.CurrentTurnSeq, plan.CurrentRequestID)
|
||||
preservedIndexes := autoCompactionPreservedEntryIndexes(entries, plan.CurrentTurnSeq, plan.CurrentRequestID, latestToolCallID)
|
||||
if len(preservedIndexes) == 0 {
|
||||
return nil
|
||||
}
|
||||
preserved := make([]HistoryEntry, 0, len(preservedIndexes))
|
||||
for index, entry := range entries {
|
||||
if _, ok := preservedIndexes[index]; !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(entry.Kind) {
|
||||
case "compaction_summary", "compacted_summary", "compaction_request":
|
||||
continue
|
||||
case "tool_result":
|
||||
if rewritten, ok := rewriteAutoCompactionToolResultEntry(entry, autoCompactionPreservedToolResultLimitBytes, false); ok {
|
||||
entry = rewritten
|
||||
}
|
||||
}
|
||||
preserved = append(preserved, entry)
|
||||
}
|
||||
return preserved
|
||||
}
|
||||
|
||||
func newCompactionSummaryEntry(plan *PendingCompaction, summaryText string) HistoryEntry {
|
||||
payload, _ := json.Marshal(compactionSummaryEntryPayload{
|
||||
Summary: strings.TrimSpace(summaryText),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestApplyCompactionToConversationPreservesCanonicalHistory(t *testing.T) {
|
||||
conversation := compactionAppendOnlyConversation(t)
|
||||
originalEntries := append([]HistoryEntry(nil), conversation.Entries...)
|
||||
plan := &PendingCompaction{
|
||||
Trigger: "manual",
|
||||
CurrentTurnSeq: 2,
|
||||
CurrentRequestID: "request-2",
|
||||
}
|
||||
|
||||
if err := applyCompactionToConversation(conversation, plan, "earlier context summary"); err != nil {
|
||||
t.Fatalf("applyCompactionToConversation() error = %v", err)
|
||||
}
|
||||
if len(conversation.Entries) <= len(originalEntries) {
|
||||
t.Fatalf("entries after compaction = %d, want the %d original entries plus a summary marker", len(conversation.Entries), len(originalEntries))
|
||||
}
|
||||
if !reflect.DeepEqual(conversation.Entries[:len(originalEntries)], originalEntries) {
|
||||
t.Fatal("compaction changed the canonical history prefix")
|
||||
}
|
||||
|
||||
projector := NewHistoryProjector()
|
||||
projection, err := projector.ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(projection.State.GetTurns()) != 2 {
|
||||
t.Fatalf("checkpoint turns after compaction = %d, want 2 visible turns", len(projection.State.GetTurns()))
|
||||
}
|
||||
replay, err := projector.ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPromptReplay() error = %v", err)
|
||||
}
|
||||
if len(replay) != 1 || replay[0].Role != "user" || !strings.Contains(replay[0].Content, "earlier context summary") {
|
||||
t.Fatalf("prompt replay after compaction = %#v, want only the compacted summary", replay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactedPromptProjectionPlacesSummaryBeforePreservedCurrentTurn(t *testing.T) {
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 1,
|
||||
NextEntrySeq: 1,
|
||||
}
|
||||
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||
compactionTestUserEntry(t, 1, "request-1", "current question", "message-1"),
|
||||
newToolCallEntry(1, "request-1", "call-1", "Read", "", "", checkpointTestReadToolCall(t, nil)),
|
||||
newToolResultEntry(1, "request-1", "call-1", "Read", `{"path":"/tmp/example.txt"}`, "file contents", "", checkpointTestReadToolCall(t, nil)),
|
||||
})
|
||||
plan := &PendingCompaction{
|
||||
Trigger: "auto",
|
||||
CurrentTurnSeq: 1,
|
||||
CurrentRequestID: "request-1",
|
||||
PreserveCurrentTurnInputs: true,
|
||||
}
|
||||
if err := applyCompactionToConversation(conversation, plan, "current progress summary"); err != nil {
|
||||
t.Fatalf("applyCompactionToConversation() error = %v", err)
|
||||
}
|
||||
|
||||
projected := compactedPromptProjectionEntries(conversation.Entries)
|
||||
promptKinds := make([]string, 0, len(projected))
|
||||
for _, entry := range projected {
|
||||
if isPromptReplayEntryKind(entry.Kind) {
|
||||
promptKinds = append(promptKinds, entry.Kind)
|
||||
}
|
||||
}
|
||||
want := []string{"compacted_summary", "user_message", "tool_call", "tool_result"}
|
||||
if !reflect.DeepEqual(promptKinds, want) {
|
||||
t.Fatalf("compacted prompt entry order = %#v, want %#v", promptKinds, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPlanningDoesNotRecompactArchivedHistory(t *testing.T) {
|
||||
conversation := compactionAppendOnlyConversation(t)
|
||||
if err := applyCompactionToConversation(conversation, &PendingCompaction{
|
||||
Trigger: "manual",
|
||||
CurrentTurnSeq: 2,
|
||||
CurrentRequestID: "request-2",
|
||||
}, "archived history summary"); err != nil {
|
||||
t.Fatalf("applyCompactionToConversation() error = %v", err)
|
||||
}
|
||||
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||
compactionTestUserEntry(t, 3, "request-3", "new question", "message-3"),
|
||||
})
|
||||
|
||||
plan, err := (&Service{}).buildLegacyCompactionPlan(&compactionPlan{
|
||||
CurrentTurnSeq: 3,
|
||||
CurrentRequestID: "request-3",
|
||||
}, conversation, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("buildLegacyCompactionPlan() error = %v", err)
|
||||
}
|
||||
if plan != nil {
|
||||
t.Fatalf("buildLegacyCompactionPlan() = %#v, want no already summarized candidates", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCompactionPlanPersistsHistoryAppendOnly(t *testing.T) {
|
||||
store := NewConversationFileStore(t.TempDir())
|
||||
conversation := compactionAppendOnlyConversation(t)
|
||||
if _, _, err := store.AppendEntries(conversation.ConversationID, resetEntrySequences(conversation.Entries)); err != nil {
|
||||
t.Fatalf("AppendEntries() error = %v", err)
|
||||
}
|
||||
persisted, err := store.LoadConversation(conversation.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("initial LoadConversation() error = %v", err)
|
||||
}
|
||||
originalEntries := append([]HistoryEntry(nil), persisted.Entries...)
|
||||
projector := NewHistoryProjector()
|
||||
service := &Service{
|
||||
store: store,
|
||||
projector: projector,
|
||||
compiler: compactionProjectionCompiler{projector: projector},
|
||||
}
|
||||
stream := &ActiveStream{
|
||||
RequestID: "request-2",
|
||||
ConversationID: conversation.ConversationID,
|
||||
TurnSeq: 2,
|
||||
Mode: agentv1.AgentMode_AGENT_MODE_AGENT,
|
||||
CheckpointConversation: persisted,
|
||||
}
|
||||
plan := &PendingCompaction{
|
||||
Trigger: "manual",
|
||||
CurrentTurnSeq: 2,
|
||||
CurrentRequestID: "request-2",
|
||||
ContextWindowSize: 1_000_000,
|
||||
}
|
||||
if err := service.applyCompactionPlan(stream, conversation.ConversationID, plan, "persisted summary"); err != nil {
|
||||
t.Fatalf("applyCompactionPlan() error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadConversation(conversation.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
if len(loaded.Entries) <= len(originalEntries) {
|
||||
t.Fatalf("persisted entries after compaction = %d, want more than %d", len(loaded.Entries), len(originalEntries))
|
||||
}
|
||||
for index := range originalEntries {
|
||||
if !reflect.DeepEqual(loaded.Entries[index], originalEntries[index]) {
|
||||
t.Fatalf("persisted history entry %d changed after compaction:\ngot %#v\nwant %#v", index, loaded.Entries[index], originalEntries[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type compactionProjectionCompiler struct {
|
||||
projector *HistoryProjector
|
||||
}
|
||||
|
||||
func (compiler compactionProjectionCompiler) Compile(conversation *ConversationFile, _ agentv1.AgentMode, _ string, _ string) (CompiledConversation, error) {
|
||||
messages, err := compiler.projector.ProjectPromptReplay(conversation)
|
||||
return CompiledConversation{Messages: messages}, err
|
||||
}
|
||||
|
||||
func (compactionProjectionCompiler) DerivePromptContexts(*ConversationFile, agentv1.AgentMode, string) ([]PromptContextMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func compactionAppendOnlyConversation(t *testing.T) *ConversationFile {
|
||||
t.Helper()
|
||||
conversation := &ConversationFile{
|
||||
ConversationID: "conversation-1",
|
||||
RootConversationID: "conversation-1",
|
||||
Mode: "agent",
|
||||
NextTurnSeq: 1,
|
||||
NextEntrySeq: 1,
|
||||
TokenDetailsUsedTokens: 42_000,
|
||||
TokenDetailsMaxTokens: 50_000,
|
||||
}
|
||||
appendEntriesInPlace(conversation, []HistoryEntry{
|
||||
compactionTestUserEntry(t, 1, "request-1", "first question", "message-1"),
|
||||
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
|
||||
compactionTestUserEntry(t, 2, "request-2", "second question", "message-2"),
|
||||
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
|
||||
})
|
||||
return conversation
|
||||
}
|
||||
|
||||
func compactionTestUserEntry(t *testing.T, turnSeq int64, requestID string, text string, messageID string) HistoryEntry {
|
||||
t.Helper()
|
||||
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: text, MessageId: messageID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal user message: %v", err)
|
||||
}
|
||||
return HistoryEntry{
|
||||
TurnSeq: turnSeq,
|
||||
RequestID: requestID,
|
||||
Role: "user",
|
||||
Kind: "user_message",
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
var _ PromptCompiler = compactionProjectionCompiler{}
|
||||
@@ -121,10 +121,15 @@ func (store *ConversationFileStore) LoadConversation(conversationID string) (*Co
|
||||
|
||||
// AppendEntries 把已经发生的语义事件追加到 context.json,并同步 state.json。
|
||||
func (store *ConversationFileStore) AppendEntries(conversationID string, entries []HistoryEntry) (*ConversationFile, []HistoryEntry, error) {
|
||||
return store.AppendEntriesWithUpdate(conversationID, entries, nil)
|
||||
}
|
||||
|
||||
// AppendEntriesWithUpdate 原子追加 context entries,并在同一把会话锁内更新 state metadata。
|
||||
func (store *ConversationFileStore) AppendEntriesWithUpdate(conversationID string, entries []HistoryEntry, update func(*ConversationFile) error) (*ConversationFile, []HistoryEntry, error) {
|
||||
if store == nil {
|
||||
return nil, nil, fmt.Errorf("conversation file store is nil")
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
if len(entries) == 0 && update == nil {
|
||||
conversation, err := store.LoadConversation(conversationID)
|
||||
return conversation, nil, err
|
||||
}
|
||||
@@ -162,6 +167,11 @@ func (store *ConversationFileStore) AppendEntries(conversationID string, entries
|
||||
conversation.Mode = alias
|
||||
}
|
||||
assigned := appendEntriesInPlace(conversation, entries)
|
||||
if update != nil {
|
||||
if err := update(conversation); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
deriveConversationLoopState(conversation)
|
||||
if err := store.writeConversationLocked(normalizedConversationID, conversation); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -762,6 +772,7 @@ func mergeConversationMetadata(target *ConversationFile, source *ConversationFil
|
||||
target.CurrentPlanText = source.CurrentPlanText
|
||||
target.CurrentPlans = clonePlanRegistryEntries(source.CurrentPlans)
|
||||
target.CurrentTodos = cloneTodoItems(source.CurrentTodos)
|
||||
target.ImportedTurnIDs = cloneByteSlices(source.ImportedTurnIDs)
|
||||
target.LatestRequestPrefix = cloneConversationRequestPrefix(source.LatestRequestPrefix)
|
||||
target.LastProviderCall = cloneConversationProviderCall(source.LastProviderCall)
|
||||
if !source.CreatedAt.IsZero() && (target.CreatedAt.IsZero() || source.CreatedAt.Before(target.CreatedAt)) {
|
||||
@@ -894,6 +905,7 @@ func cloneConversationFile(conversation *ConversationFile) *ConversationFile {
|
||||
cloned := *conversation
|
||||
cloned.CurrentPlans = clonePlanRegistryEntries(conversation.CurrentPlans)
|
||||
cloned.CurrentTodos = cloneTodoItems(conversation.CurrentTodos)
|
||||
cloned.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
|
||||
cloned.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
||||
cloned.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
||||
cloned.Entries = append([]HistoryEntry(nil), conversation.Entries...)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
modeladapter "cursor/internal/backend/agent/model"
|
||||
promptengine "cursor/internal/backend/agent/prompt"
|
||||
)
|
||||
|
||||
type importedBlobStore map[string][]byte
|
||||
|
||||
func newImportedBlobStore(items []*agentv1.PreFetchedBlob) (importedBlobStore, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
store := make(importedBlobStore, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || len(item.GetId()) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(item.GetId()) != sha256.Size {
|
||||
return nil, fmt.Errorf("prefetched blob id length %d, want %d", len(item.GetId()), sha256.Size)
|
||||
}
|
||||
digest := sha256.Sum256(item.GetValue())
|
||||
if string(digest[:]) != string(item.GetId()) {
|
||||
return nil, fmt.Errorf("prefetched blob %x failed SHA-256 validation", item.GetId())
|
||||
}
|
||||
store[string(item.GetId())] = append([]byte(nil), item.GetValue()...)
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (store importedBlobStore) resolve(id []byte) ([]byte, bool) {
|
||||
if len(id) == 0 || len(store) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := store[string(id)]
|
||||
return append([]byte(nil), value...), ok
|
||||
}
|
||||
|
||||
func decodeImportedTurn(raw []byte, blobs importedBlobStore) (*agentv1.ConversationTurnStructure, []byte, error) {
|
||||
if data, ok := blobs.resolve(raw); ok {
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(data, turn); err != nil || turn.GetTurn() == nil {
|
||||
return nil, nil, fmt.Errorf("decode imported turn blob %x: %w", raw, firstNonNilError(err, fmt.Errorf("turn payload is empty")))
|
||||
}
|
||||
return turn, append([]byte(nil), raw...), nil
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(raw, turn); err == nil && turn.GetTurn() != nil {
|
||||
return turn, nil, nil
|
||||
}
|
||||
if len(raw) == sha256.Size {
|
||||
return nil, append([]byte(nil), raw...), nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("decode imported inline turn")
|
||||
}
|
||||
|
||||
func decodeImportedUserMessage(raw []byte, blobs importedBlobStore) (*agentv1.UserMessage, error) {
|
||||
data := raw
|
||||
if resolved, ok := blobs.resolve(raw); ok {
|
||||
data = resolved
|
||||
} else if len(raw) == sha256.Size {
|
||||
candidate := &agentv1.UserMessage{}
|
||||
if err := proto.Unmarshal(raw, candidate); err != nil || !hasKnownUserMessageContent(candidate) {
|
||||
return nil, fmt.Errorf("missing prefetched user message blob %x", raw)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
message := &agentv1.UserMessage{}
|
||||
if err := proto.Unmarshal(data, message); err != nil {
|
||||
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func decodeImportedStep(raw []byte, blobs importedBlobStore) (*agentv1.ConversationStep, error) {
|
||||
data := raw
|
||||
if resolved, ok := blobs.resolve(raw); ok {
|
||||
data = resolved
|
||||
} else if len(raw) == sha256.Size {
|
||||
candidate := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(raw, candidate); err != nil || candidate.GetMessage() == nil {
|
||||
return nil, fmt.Errorf("missing prefetched conversation step blob %x", raw)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(data, step); err != nil {
|
||||
return nil, fmt.Errorf("decode imported turn step: %w", err)
|
||||
}
|
||||
if step.GetMessage() == nil {
|
||||
return nil, fmt.Errorf("decode imported turn step: payload is empty")
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
func importedBlobTurnMessages(turn *agentv1.ConversationTurnStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
|
||||
if turn == nil || turn.GetAgentConversationTurn() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
messages := make([]modeladapter.Message, 0, 1+len(agentTurn.GetSteps()))
|
||||
if len(agentTurn.GetUserMessage()) > 0 {
|
||||
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
|
||||
messages = append(messages, toModelMessage(replay))
|
||||
}
|
||||
}
|
||||
for _, rawStep := range agentTurn.GetSteps() {
|
||||
if len(rawStep) == 0 {
|
||||
continue
|
||||
}
|
||||
step, err := decodeImportedStep(rawStep, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
|
||||
messages = append(messages, toModelMessage(replay))
|
||||
}
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func importedTurnIDs(turns [][]byte, blobs importedBlobStore) ([][]byte, error) {
|
||||
ids := make([][]byte, 0, len(turns))
|
||||
for _, raw := range turns {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
_, id, err := decodeImportedTurn(raw, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(id) > 0 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func hasKnownUserMessageContent(message *agentv1.UserMessage) bool {
|
||||
if message == nil {
|
||||
return false
|
||||
}
|
||||
return message.GetText() != "" ||
|
||||
message.GetMessageId() != "" ||
|
||||
message.GetSelectedContext() != nil ||
|
||||
message.GetRichText() != "" ||
|
||||
len(message.GetConversationStateBlobId()) > 0 ||
|
||||
len(message.GetTextBlobId()) > 0 ||
|
||||
len(message.GetRichTextBlobId()) > 0
|
||||
}
|
||||
|
||||
func firstNonNilError(err error, fallback error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"cursor/gen/agentv1"
|
||||
)
|
||||
|
||||
func TestImportedConversationStateRestoresBlobOnlyForkAndCheckpointPrefix(t *testing.T) {
|
||||
parent := compactionAppendOnlyConversation(t)
|
||||
parent.Entries = parent.Entries[:2]
|
||||
parent.NextEntrySeq = 3
|
||||
parent.NextTurnSeq = 2
|
||||
projection, err := NewHistoryProjector().ProjectCheckpointProjection(parent)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
prefetched := make([]*agentv1.PreFetchedBlob, 0, len(projection.Blobs))
|
||||
for _, blob := range projection.Blobs {
|
||||
prefetched = append(prefetched, &agentv1.PreFetchedBlob{Id: blob.ID, Value: blob.Data})
|
||||
}
|
||||
state := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
|
||||
state.RootPromptMessagesJson = nil
|
||||
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
|
||||
if err != nil {
|
||||
t.Fatalf("newRuntimeConversation() error = %v", err)
|
||||
}
|
||||
entries, err := (&Service{}).importConversationState(conversation, state, prefetched)
|
||||
if err != nil {
|
||||
t.Fatalf("importConversationState() error = %v", err)
|
||||
}
|
||||
if len(conversation.ImportedTurnIDs) != 1 || conversation.NextTurnSeq != 2 {
|
||||
t.Fatalf("imported prefix turns=%d next_turn_seq=%d, want 1 and 2", len(conversation.ImportedTurnIDs), conversation.NextTurnSeq)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("imported model entries = %d, want parent user and assistant", len(entries))
|
||||
}
|
||||
appendEntriesInPlace(conversation, append(entries,
|
||||
compactionTestUserEntry(t, 2, "request-2", "fork question", "message-2"),
|
||||
))
|
||||
forkProjection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
|
||||
if err != nil {
|
||||
t.Fatalf("fork ProjectCheckpointProjection() error = %v", err)
|
||||
}
|
||||
if len(forkProjection.State.GetTurns()) != 2 {
|
||||
t.Fatalf("fork checkpoint turns = %d, want imported parent plus local fork turn", len(forkProjection.State.GetTurns()))
|
||||
}
|
||||
if string(forkProjection.State.GetTurns()[0]) != string(projection.State.GetTurns()[0]) {
|
||||
t.Fatal("fork checkpoint did not preserve the imported parent turn ID as its prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedConversationStateRejectsUnresolvedBlobTurn(t *testing.T) {
|
||||
turnID := sha256.Sum256([]byte("missing imported turn"))
|
||||
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
|
||||
if err != nil {
|
||||
t.Fatalf("newRuntimeConversation() error = %v", err)
|
||||
}
|
||||
if _, err := (&Service{}).importConversationState(conversation, &agentv1.ConversationStateStructure{
|
||||
Turns: [][]byte{turnID[:]},
|
||||
}, nil); err == nil {
|
||||
t.Fatal("importConversationState() accepted an unresolved Blob turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedTurnIDsPersistThroughConversationStore(t *testing.T) {
|
||||
store := NewConversationFileStore(t.TempDir())
|
||||
turnID := sha256.Sum256([]byte("parent turn"))
|
||||
conversation, err := newRuntimeConversation("fork-conversation", agentv1.AgentMode_AGENT_MODE_AGENT)
|
||||
if err != nil {
|
||||
t.Fatalf("newRuntimeConversation() error = %v", err)
|
||||
}
|
||||
conversation.ImportedTurnIDs = [][]byte{turnID[:]}
|
||||
persisted, err := store.SaveConversationWithEntries(conversation.ConversationID, conversation, []HistoryEntry{
|
||||
compactionTestUserEntry(t, 2, "request-2", "fork question", "message-2"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
if len(persisted.ImportedTurnIDs) != 1 || string(persisted.ImportedTurnIDs[0]) != string(turnID[:]) {
|
||||
t.Fatalf("persisted ImportedTurnIDs = %x, want %x", persisted.ImportedTurnIDs, turnID)
|
||||
}
|
||||
loaded, err := store.LoadConversation(conversation.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
if len(loaded.ImportedTurnIDs) != 1 || string(loaded.ImportedTurnIDs[0]) != string(turnID[:]) {
|
||||
t.Fatalf("loaded ImportedTurnIDs = %x, want %x", loaded.ImportedTurnIDs, turnID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindImportedTurnPrefixUsesClientForkPoint(t *testing.T) {
|
||||
ids := make([][]byte, 3)
|
||||
for index := range ids {
|
||||
digest := sha256.Sum256([]byte{byte(index + 1)})
|
||||
ids[index] = digest[:]
|
||||
}
|
||||
trimmed := rewindImportedTurnPrefix(ids, runRewindDecision{
|
||||
TargetTurnSeq: 4,
|
||||
HasClientTurnCount: true,
|
||||
ClientTurnCount: 1,
|
||||
})
|
||||
if len(trimmed) != 1 || string(trimmed[0]) != string(ids[0]) {
|
||||
t.Fatalf("rewindImportedTurnPrefix() = %x, want first imported turn only", trimmed)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package forwarder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -125,6 +126,53 @@ func TestCancelPersistsInterruptedProviderOutputIdempotently(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericProviderFailurePersistsAccumulatedOutput(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotCheckpointConversation() error = %v", err)
|
||||
}
|
||||
if _, err := service.store.SaveConversationWithEntries(stream.ConversationID, conversation, conversation.Entries); err != nil {
|
||||
t.Fatalf("SaveConversationWithEntries() error = %v", err)
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
stream.CurrentModelCallID = "model-call-1"
|
||||
stream.ProviderActive = true
|
||||
stream.ProviderAccumulatedText = "partial answer before transport failure"
|
||||
stream.Status = StreamStatusStreaming
|
||||
stream.Phase = TurnPhaseProviderRunning
|
||||
stream.mu.Unlock()
|
||||
if err := service.handleProviderDoneEvent(stream, &streamProviderEvent{
|
||||
Done: true,
|
||||
Err: errors.New("transport failed"),
|
||||
}); err != nil {
|
||||
t.Fatalf("handleProviderDoneEvent() error = %v", err)
|
||||
}
|
||||
|
||||
persisted, err := service.store.LoadConversation(stream.ConversationID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConversation() error = %v", err)
|
||||
}
|
||||
foundPartialOutput := false
|
||||
for _, entry := range persisted.Entries {
|
||||
if entry.Kind != "assistant_text" {
|
||||
continue
|
||||
}
|
||||
var payload assistantTextPayload
|
||||
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
|
||||
t.Fatalf("decode assistant entry: %v", err)
|
||||
}
|
||||
if payload.Text == "partial answer before transport failure" {
|
||||
foundPartialOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundPartialOutput {
|
||||
t.Fatal("generic provider failure discarded accumulated assistant output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelPreservesPersistedTurnActivityWithoutLiveAccumulator(t *testing.T) {
|
||||
service, stream, _ := testCheckpointBlobProjection(t)
|
||||
conversation, _, _, err := service.snapshotCheckpointConversation(stream)
|
||||
|
||||
@@ -326,20 +326,24 @@ func compactedPromptProjectionEntries(entries []HistoryEntry) []HistoryEntry {
|
||||
latestToolCallID := latestCompletedToolCallIDForTurn(entries, compactionPayload.CurrentTurnSeq, compactionPayload.CurrentRequestID)
|
||||
preservedIndexes = autoCompactionPreservedEntryIndexes(entries, compactionPayload.CurrentTurnSeq, compactionPayload.CurrentRequestID, latestToolCallID)
|
||||
}
|
||||
filtered := make([]HistoryEntry, 0, len(entries)-compactionIndex)
|
||||
for index, entry := range entries {
|
||||
if index < compactionIndex && isPromptReplayEntryKind(entry.Kind) {
|
||||
if _, ok := preservedIndexes[index]; !ok {
|
||||
continue
|
||||
}
|
||||
filtered := make([]HistoryEntry, 0, len(entries)-compactionIndex+len(preservedIndexes))
|
||||
for index := 0; index < compactionIndex; index++ {
|
||||
if !isPromptReplayEntryKind(entries[index].Kind) {
|
||||
filtered = append(filtered, entries[index])
|
||||
}
|
||||
if index < compactionIndex {
|
||||
if rewritten, ok := compactedProjectionPreservedEntry(entry); ok {
|
||||
entry = rewritten
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, entries[compactionIndex])
|
||||
for index := 0; index < compactionIndex; index++ {
|
||||
if _, ok := preservedIndexes[index]; !ok || isCompactionSummaryKind(entries[index].Kind) {
|
||||
continue
|
||||
}
|
||||
entry := entries[index]
|
||||
if rewritten, ok := compactedProjectionPreservedEntry(entry); ok {
|
||||
entry = rewritten
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
filtered = append(filtered, entries[compactionIndex+1:]...)
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -575,7 +579,7 @@ func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *Con
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Turns = turnIDs
|
||||
state.Turns = append(cloneByteSlices(conversation.ImportedTurnIDs), turnIDs...)
|
||||
replayMessages, err := projector.ProjectPromptReplay(conversation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1292,7 +1296,7 @@ func filterCheckpointPersistentToolReplay(messages []promptengine.Message) []pro
|
||||
return filtered
|
||||
}
|
||||
|
||||
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte) []promptengine.Message {
|
||||
func restoreImportedReplayUserMessages(messages []promptengine.Message, importedTurns [][]byte, blobs importedBlobStore) []promptengine.Message {
|
||||
if len(messages) == 0 || len(importedTurns) == 0 {
|
||||
return messages
|
||||
}
|
||||
@@ -1301,16 +1305,16 @@ func restoreImportedReplayUserMessages(messages []promptengine.Message, imported
|
||||
if len(rawTurn) == 0 {
|
||||
continue
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
||||
turn, _, err := decodeImportedTurn(rawTurn, blobs)
|
||||
if err != nil || turn == nil {
|
||||
continue
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil || len(agentTurn.GetUserMessage()) == 0 {
|
||||
continue
|
||||
}
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := proto.Unmarshal(agentTurn.GetUserMessage(), userMessage); err != nil {
|
||||
userMessage, err := decodeImportedUserMessage(agentTurn.GetUserMessage(), blobs)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage)
|
||||
|
||||
@@ -224,6 +224,7 @@ func (service *Service) applyRunRewindToConversation(conversation *ConversationF
|
||||
conversation.Entries = nil
|
||||
conversation.NextEntrySeq = 1
|
||||
conversation.NextTurnSeq = 1
|
||||
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(conversation.ImportedTurnIDs, decision)
|
||||
appendEntriesInPlace(conversation, appendReplacementRunEntries(decision.PrefixEntries, entries))
|
||||
applyRunRewindConversationState(conversation, intent, turnSeq)
|
||||
deriveConversationLoopState(conversation)
|
||||
@@ -269,10 +270,30 @@ func applyRunRewindMetadata(conversation *ConversationFile, source *Conversation
|
||||
if source.TokenDetailsMaxTokens > 0 {
|
||||
conversation.TokenDetailsMaxTokens = source.TokenDetailsMaxTokens
|
||||
}
|
||||
decision := runRewindDecision{TargetTurnSeq: turnSeq}
|
||||
if intent.ConversationState != nil {
|
||||
decision.HasClientTurnCount = true
|
||||
decision.ClientTurnCount = len(intent.ConversationState.GetTurns())
|
||||
}
|
||||
conversation.ImportedTurnIDs = rewindImportedTurnPrefix(source.ImportedTurnIDs, decision)
|
||||
}
|
||||
applyRunRewindConversationState(conversation, intent, turnSeq)
|
||||
}
|
||||
|
||||
func rewindImportedTurnPrefix(importedTurnIDs [][]byte, decision runRewindDecision) [][]byte {
|
||||
keep := decision.TargetTurnSeq - 1
|
||||
if decision.HasClientTurnCount {
|
||||
keep = int64(decision.ClientTurnCount)
|
||||
}
|
||||
if keep <= 0 || len(importedTurnIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if keep > int64(len(importedTurnIDs)) {
|
||||
keep = int64(len(importedTurnIDs))
|
||||
}
|
||||
return cloneByteSlices(importedTurnIDs[:keep])
|
||||
}
|
||||
|
||||
func (service *Service) logRunRewindDecision(requestID string, conversationID string, eventName string, decision runRewindDecision) {
|
||||
if service == nil || !decision.Evaluated {
|
||||
return
|
||||
|
||||
@@ -50,7 +50,7 @@ func (service *Service) bootstrapRuntimeConversation(intent InboundIntent) (*Con
|
||||
}
|
||||
importedEntries := []HistoryEntry(nil)
|
||||
if len(conversation.Entries) == 0 && intent.ConversationState != nil {
|
||||
importedEntries, err = service.importConversationState(conversation, intent.ConversationState)
|
||||
importedEntries, err = service.importConversationState(conversation, intent.ConversationState, intent.PreFetchedBlobs)
|
||||
if err != nil {
|
||||
return nil, agentv1.AgentMode_AGENT_MODE_AGENT, 0, nil, err
|
||||
}
|
||||
@@ -138,6 +138,7 @@ func (service *Service) syncConversationRecord(conversationID string, conversati
|
||||
item.AutoCompactionReserveTokens = conversation.AutoCompactionReserveTokens
|
||||
item.AutoCompactionTriggeredAt = conversation.AutoCompactionTriggeredAt
|
||||
item.AutoCompactionSourceModelCallID = conversation.AutoCompactionSourceModelCallID
|
||||
item.ImportedTurnIDs = cloneByteSlices(conversation.ImportedTurnIDs)
|
||||
item.LatestRequestPrefix = cloneConversationRequestPrefix(conversation.LatestRequestPrefix)
|
||||
item.LastProviderCall = cloneConversationProviderCall(conversation.LastProviderCall)
|
||||
item.CreatedAt = conversation.CreatedAt
|
||||
|
||||
@@ -559,6 +559,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
|
||||
}
|
||||
intent.ConversationID = conversationID
|
||||
intent.ConversationState = runRequest.GetConversationState()
|
||||
intent.PreFetchedBlobs = runRequest.GetPreFetchedBlobs()
|
||||
intent.UserMessage = extractUserMessage(message)
|
||||
intent.RequestContext = extractRequestContext(message)
|
||||
if service.shouldIgnoreEmptyResumeRunRequest(requestID, runRequest, intent.UserMessage, intent.RequestContext) {
|
||||
@@ -606,6 +607,7 @@ func (service *Service) decodeInboundIntent(requestID string, message *agentv1.A
|
||||
intent.ConversationID = conversationID
|
||||
intent.SubagentTypeName = strings.TrimSpace(prewarmRequest.GetSubagentTypeName())
|
||||
intent.ConversationState = prewarmRequest.GetConversationState()
|
||||
intent.PreFetchedBlobs = prewarmRequest.GetPreFetchedBlobs()
|
||||
intent.Mode, intent.ModeSource, intent.HasExplicitMode, err = extractPrewarmMode(prewarmRequest)
|
||||
if err != nil {
|
||||
return InboundIntent{}, err
|
||||
@@ -2246,6 +2248,15 @@ func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) finishFailedTurnAfterCheckpoint(stream *ActiveStream, terminalCode string, terminalMessage string) error {
|
||||
if stream == nil {
|
||||
return nil
|
||||
}
|
||||
err := service.broker.Fail(stream.RequestID, terminalCode, terminalMessage)
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCode string, cause error) error {
|
||||
if stream == nil || cause == nil {
|
||||
return nil
|
||||
@@ -2265,6 +2276,10 @@ func (service *Service) publishCheckpoint(requestID string, conversationID strin
|
||||
}
|
||||
|
||||
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
|
||||
return service.publishCheckpointWithTerminalAction(requestID, successfulCheckpointTerminalAction(completion))
|
||||
}
|
||||
|
||||
func (service *Service) publishCheckpointWithTerminalAction(requestID string, terminal checkpointTerminalAction) error {
|
||||
stream, ok := service.broker.Get(requestID)
|
||||
if !ok || stream == nil {
|
||||
return fmt.Errorf("request is not active: %s", requestID)
|
||||
@@ -2282,7 +2297,7 @@ func (service *Service) publishCheckpointWithCompletion(requestID string, _ stri
|
||||
}
|
||||
projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
|
||||
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
|
||||
return service.queueCheckpointProjection(stream, projection, completion)
|
||||
return service.queueCheckpointProjectionWithTerminal(stream, projection, terminal)
|
||||
}
|
||||
|
||||
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
|
||||
@@ -2422,18 +2437,20 @@ func (service *Service) failActiveStream(stream *ActiveStream, conversationID st
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
service.setTurnPhase(stream, TurnPhaseFailed)
|
||||
var firstErr error
|
||||
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
if err := service.syncSummaryCarryForward(conversationID, requestID, modelCallID); err != nil {
|
||||
log.Printf(
|
||||
"forwarder summary sync before failed terminal skipped request_id=%s model_call_id=%s err=%v",
|
||||
strings.TrimSpace(requestID),
|
||||
strings.TrimSpace(modelCallID),
|
||||
err,
|
||||
)
|
||||
}
|
||||
if err := service.publishCheckpoint(requestID, conversationID); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
terminal := failedCheckpointTerminalAction(terminalCode, terminalMessage)
|
||||
if err := service.publishCheckpointWithTerminalAction(requestID, terminal); err != nil {
|
||||
log.Printf("forwarder checkpoint queue before failed terminal skipped request_id=%s err=%v", strings.TrimSpace(requestID), err)
|
||||
return service.finishFailedTurnAfterCheckpoint(stream, terminalCode, terminalMessage)
|
||||
}
|
||||
if err := service.broker.Fail(requestID, terminalCode, terminalMessage); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRunEntries 构造一次 run intent 需要写入 history 的首批 entry。
|
||||
|
||||
@@ -45,13 +45,25 @@ func (snapshot turnUsageSnapshot) requestTokensTotal() int64 {
|
||||
return snapshot.promptTokensTotal() + nonNegativeInt64(snapshot.OutputTokens)
|
||||
}
|
||||
|
||||
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure) ([]HistoryEntry, error) {
|
||||
func (service *Service) importConversationState(item *ConversationFile, state *agentv1.ConversationStateStructure, prefetchedBlobs []*agentv1.PreFetchedBlob) ([]HistoryEntry, error) {
|
||||
if item == nil || state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
blobs, err := newImportedBlobStore(prefetchedBlobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
importedIDs, err := importedTurnIDs(state.GetTurns(), blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.TokenDetailsUsedTokens = state.GetTokenDetails().GetUsedTokens()
|
||||
item.ImportedTurnIDs = importedIDs
|
||||
if minimumNextTurnSeq := int64(len(item.ImportedTurnIDs)) + 1; item.NextTurnSeq < minimumNextTurnSeq {
|
||||
item.NextTurnSeq = minimumNextTurnSeq
|
||||
}
|
||||
entries := make([]HistoryEntry, 0, 2)
|
||||
if messages, err := importedConversationStateModelMessages(state); err != nil {
|
||||
if messages, err := importedConversationStateModelMessagesWithBlobs(state, blobs); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, message := range messages {
|
||||
@@ -105,6 +117,10 @@ func (service *Service) importConversationState(item *ConversationFile, state *a
|
||||
}
|
||||
|
||||
func importedConversationStateModelMessages(state *agentv1.ConversationStateStructure) ([]modeladapter.Message, error) {
|
||||
return importedConversationStateModelMessagesWithBlobs(state, nil)
|
||||
}
|
||||
|
||||
func importedConversationStateModelMessagesWithBlobs(state *agentv1.ConversationStateStructure, blobs importedBlobStore) ([]modeladapter.Message, error) {
|
||||
if state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -113,7 +129,7 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode imported replay messages: %w", err)
|
||||
}
|
||||
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns())
|
||||
decoded = restoreImportedReplayUserMessages(decoded, state.GetTurns(), blobs)
|
||||
decoded = filterLegacyPlainWriteReplay(decoded)
|
||||
decoded = filterInternalPromptContextReplay(decoded)
|
||||
messages := make([]modeladapter.Message, 0, len(decoded))
|
||||
@@ -133,35 +149,18 @@ func importedConversationStateModelMessages(state *agentv1.ConversationStateStru
|
||||
if len(rawTurn) == 0 {
|
||||
continue
|
||||
}
|
||||
turn := &agentv1.ConversationTurnStructure{}
|
||||
if err := proto.Unmarshal(rawTurn, turn); err != nil {
|
||||
return nil, fmt.Errorf("decode imported turn: %w", err)
|
||||
turn, turnID, err := decodeImportedTurn(rawTurn, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentTurn := turn.GetAgentConversationTurn()
|
||||
if agentTurn == nil {
|
||||
continue
|
||||
if turn == nil && len(turnID) > 0 {
|
||||
return nil, fmt.Errorf("missing prefetched turn blob %x", turnID)
|
||||
}
|
||||
if rawUser := agentTurn.GetUserMessage(); len(rawUser) > 0 {
|
||||
userMessage := &agentv1.UserMessage{}
|
||||
if err := proto.Unmarshal(rawUser, userMessage); err != nil {
|
||||
return nil, fmt.Errorf("decode imported turn user_message: %w", err)
|
||||
}
|
||||
if replay, ok := promptengine.BuildUserMessageReplayMessage(userMessage); ok {
|
||||
messages = append(messages, toModelMessage(replay))
|
||||
}
|
||||
}
|
||||
for _, rawStep := range agentTurn.GetSteps() {
|
||||
if len(rawStep) == 0 {
|
||||
continue
|
||||
}
|
||||
step := &agentv1.ConversationStep{}
|
||||
if err := proto.Unmarshal(rawStep, step); err != nil {
|
||||
return nil, fmt.Errorf("decode imported turn step: %w", err)
|
||||
}
|
||||
for _, replay := range promptengine.BuildLegacyMessagesFromConversationStep(step) {
|
||||
messages = append(messages, toModelMessage(replay))
|
||||
}
|
||||
turnMessages, err := importedBlobTurnMessages(turn, blobs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = append(messages, turnMessages...)
|
||||
}
|
||||
return normalizeReplayMessageSequence(messages), nil
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type ConversationFile struct {
|
||||
CurrentPlanText string `json:"current_plan_text,omitempty"`
|
||||
CurrentPlans map[string]*agentv1.PlanRegistryEntry `json:"current_plans,omitempty"`
|
||||
CurrentTodos []*agentv1.TodoItem `json:"current_todos,omitempty"`
|
||||
ImportedTurnIDs [][]byte `json:"imported_turn_ids,omitempty"`
|
||||
LatestRequestPrefix *ConversationRequestPrefix `json:"latest_request_prefix,omitempty"`
|
||||
LastProviderCall *ConversationProviderCall `json:"last_provider_call,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -224,11 +225,25 @@ type pendingTurnCompletion struct {
|
||||
Disposition pendingCompletionDisposition
|
||||
}
|
||||
|
||||
type checkpointTerminalActionKind uint8
|
||||
|
||||
const (
|
||||
checkpointTerminalActionNone checkpointTerminalActionKind = iota
|
||||
checkpointTerminalActionComplete
|
||||
checkpointTerminalActionFail
|
||||
)
|
||||
|
||||
type checkpointTerminalAction struct {
|
||||
Kind checkpointTerminalActionKind
|
||||
Completion pendingTurnCompletion
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
type pendingCheckpointPublish struct {
|
||||
State *agentv1.ConversationStateStructure
|
||||
Required map[string]struct{}
|
||||
Completion *pendingTurnCompletion
|
||||
Published bool
|
||||
State *agentv1.ConversationStateStructure
|
||||
Required map[string]struct{}
|
||||
Terminal checkpointTerminalAction
|
||||
}
|
||||
|
||||
type PendingCompaction struct {
|
||||
@@ -430,6 +445,7 @@ type InboundIntent struct {
|
||||
SubagentTypeName string
|
||||
SubagentModelOverrides map[string]runtimecore.SubagentModelOverrideSelection
|
||||
ConversationState *agentv1.ConversationStateStructure
|
||||
PreFetchedBlobs []*agentv1.PreFetchedBlob
|
||||
UserMessage *agentv1.UserMessage
|
||||
RequestContext *agentv1.RequestContext
|
||||
ClientMessage *agentv1.AgentClientMessage
|
||||
|
||||
Reference in New Issue
Block a user