Compare commits

...
Author SHA1 Message Date
leookun d3adfffbd8 Implement checkpoint blob handling in forwarder service
- Added support for checkpointing phases and blob management in the forwarder.
- Introduced new types and methods for handling checkpoint blobs, including queuing and publishing checkpoints.
- Enhanced the projector to build checkpoint projections with content-addressed blobs.
- Implemented tests to ensure proper checkpoint blob synchronization and handling of cancellation scenarios.
2026-08-05 02:09:45 +08:00
leookun 4e9335d82f Implement tests for ProjectLegacyCheckpoint to ensure proper handling of conversation state and message imports. This includes verifying that no dangling inline blobs are present and that the correct user and assistant messages are imported. 2026-08-05 01:46:47 +08:00
leokunandGitHub 374ff9c217 Merge pull request #259 from leookun/release/0.0.45
Release/0.0.45
2026-08-05 01:26:22 +08:00
leookun 06ab0d8dae chore(release): update version to 0.0.45 and fix conversation disappearance issue
- Updated version number to 0.0.45 across all relevant files.
- Fixed an issue that could cause conversations to disappear.
2026-08-05 01:25:39 +08:00
17 changed files with 895 additions and 181 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ info:
description: "Cursor助手" description: "Cursor助手"
copyright: "© 2026, Cursor助手" copyright: "© 2026, Cursor助手"
comments: "Cursor助手" comments: "Cursor助手"
version: "0.0.44" version: "0.0.45"
dev_mode: dev_mode:
root_path: . root_path: .
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.44</string> <string>0.0.45</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.44</string> <string>0.0.45</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0.0</string> <string>12.0.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.0.44</string> <string>0.0.45</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.0.44</string> <string>0.0.45</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0.0</string> <string>12.0.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+1 -1
View File
@@ -6,7 +6,7 @@
name: "Cursor助手" name: "Cursor助手"
arch: ${GOARCH} arch: ${GOARCH}
platform: "linux" platform: "linux"
version: "0.0.44" version: "0.0.45"
section: "default" section: "default"
priority: "extra" priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"fixed": { "fixed": {
"file_version": "0.0.44" "file_version": "0.0.45"
}, },
"info": { "info": {
"0000": { "0000": {
"ProductVersion": "0.0.44", "ProductVersion": "0.0.45",
"CompanyName": "Cursor助手", "CompanyName": "Cursor助手",
"FileDescription": "Cursor助手", "FileDescription": "Cursor助手",
"LegalCopyright": "© 2026, Cursor助手", "LegalCopyright": "© 2026, Cursor助手",
+1 -1
View File
@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Cursor助手" !define INFO_PRODUCTNAME "Cursor助手"
!endif !endif
!ifndef INFO_PRODUCTVERSION !ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.44" !define INFO_PRODUCTVERSION "0.0.45"
!endif !endif
!ifndef INFO_COPYRIGHT !ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Cursor助手" !define INFO_COPYRIGHT "© 2026, Cursor助手"
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.44" processorArchitecture="*"/> <assemblyIdentity type="win32" name="com.cursor.wuxianxubei" version="0.0.45" processorArchitecture="*"/>
<dependency> <dependency>
<dependentAssembly> <dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+7
View File
@@ -23,6 +23,7 @@ const (
TurnPhaseWaitingExternal TurnPhase = "waiting_external" TurnPhaseWaitingExternal TurnPhase = "waiting_external"
TurnPhaseAwaitingUser TurnPhase = "awaiting_user" TurnPhaseAwaitingUser TurnPhase = "awaiting_user"
TurnPhaseCompacting TurnPhase = "compacting" TurnPhaseCompacting TurnPhase = "compacting"
TurnPhaseCheckpointing TurnPhase = "checkpointing"
TurnPhaseCompleted TurnPhase = "completed" TurnPhaseCompleted TurnPhase = "completed"
TurnPhaseFailed TurnPhase = "failed" TurnPhaseFailed TurnPhase = "failed"
TurnPhaseCanceled TurnPhase = "canceled" TurnPhaseCanceled TurnPhase = "canceled"
@@ -66,6 +67,7 @@ const (
streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery" streamTimerNonStreamingRecovery streamTimerKind = "non_streaming_recovery"
streamTimerShellForeground streamTimerKind = "shell_foreground" streamTimerShellForeground streamTimerKind = "shell_foreground"
streamTimerShellTransportClose streamTimerKind = "shell_transport_close" streamTimerShellTransportClose streamTimerKind = "shell_transport_close"
streamTimerCheckpointBlobs streamTimerKind = "checkpoint_blobs"
streamTimerOrphanCancel streamTimerKind = "orphan_cancel" streamTimerOrphanCancel streamTimerKind = "orphan_cancel"
) )
@@ -318,6 +320,9 @@ func (service *Service) handleStreamCommand(stream *ActiveStream, command stream
case streamCommandCancel: case streamCommandCancel:
return service.handleCancelIntent(command.Intent) return service.handleCancelIntent(command.Intent)
case streamCommandMetadata: case streamCommandMetadata:
if strings.TrimSpace(command.Intent.Kind) == "kv_result" {
return service.handleCheckpointBlobResult(stream, command.Intent.KVClientMessage)
}
return service.handleMetadataIntent(command.Intent) return service.handleMetadataIntent(command.Intent)
case streamCommandExecResult: case streamCommandExecResult:
return service.handleExecResult(command.Intent) return service.handleExecResult(command.Intent)
@@ -1003,6 +1008,8 @@ func (service *Service) handleTimerEvent(stream *ActiveStream, payload *streamTi
return nil return nil
} }
return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed) return service.recoverShellWithoutTerminal(stream, current, shellRecoveryReasonTransportClosed)
case streamTimerCheckpointBlobs:
return service.handleCheckpointBlobTimeout(stream)
case streamTimerOrphanCancel: case streamTimerOrphanCancel:
stream.mu.Lock() stream.mu.Lock()
subscriberCount := len(stream.Subscribers) subscriberCount := len(stream.Subscribers)
+8
View File
@@ -76,6 +76,12 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
if existing.BackgroundShellActions == nil { if existing.BackgroundShellActions == nil {
existing.BackgroundShellActions = make(map[string]time.Time) existing.BackgroundShellActions = make(map[string]time.Time)
} }
if existing.PendingCheckpointBlobWrites == nil {
existing.PendingCheckpointBlobWrites = make(map[uint32]string)
}
if existing.ConfirmedCheckpointBlobs == nil {
existing.ConfirmedCheckpointBlobs = make(map[string]struct{})
}
existing.UpdatedAt = time.Now().UTC() existing.UpdatedAt = time.Now().UTC()
existing.mu.Unlock() existing.mu.Unlock()
return existing, nil return existing, nil
@@ -102,6 +108,8 @@ func (broker *StreamBroker) OpenStream(requestID string, conversationID string,
BackgroundShellsByMessageID: make(map[uint32]string), BackgroundShellsByMessageID: make(map[uint32]string),
BackgroundShellsByExecID: make(map[string]string), BackgroundShellsByExecID: make(map[string]string),
BackgroundShellActions: make(map[string]time.Time), BackgroundShellActions: make(map[string]time.Time),
PendingCheckpointBlobWrites: make(map[uint32]string),
ConfirmedCheckpointBlobs: make(map[string]struct{}),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -0,0 +1,238 @@
package forwarder
import (
"encoding/hex"
"fmt"
"log"
"strings"
"time"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
const checkpointBlobWriteTimeout = 5 * time.Second
type pendingCheckpointBlobWrite struct {
requestID uint32
blob CheckpointBlob
}
func clonePendingTurnCompletion(completion *pendingTurnCompletion) *pendingTurnCompletion {
if completion == nil {
return nil
}
cloned := *completion
return &cloned
}
func (service *Service) queueCheckpointProjection(stream *ActiveStream, projection *CheckpointProjection, completion *pendingTurnCompletion) error {
if service == nil || stream == nil || projection == nil || projection.State == nil {
return nil
}
state, ok := proto.Clone(projection.State).(*agentv1.ConversationStateStructure)
if !ok || state == nil {
return fmt.Errorf("clone checkpoint state")
}
stream.mu.Lock()
if stream.PendingCheckpointBlobWrites == nil {
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
}
if stream.ConfirmedCheckpointBlobs == nil {
stream.ConfirmedCheckpointBlobs = make(map[string]struct{})
}
if completion == nil && stream.PendingCheckpoint != nil {
completion = stream.PendingCheckpoint.Completion
}
required := make(map[string]struct{}, len(projection.Blobs))
pendingKeys := make(map[string]struct{}, len(stream.PendingCheckpointBlobWrites))
for _, key := range stream.PendingCheckpointBlobWrites {
pendingKeys[key] = struct{}{}
}
toWrite := make([]pendingCheckpointBlobWrite, 0, len(projection.Blobs))
for _, blob := range projection.Blobs {
key := string(blob.ID)
if key == "" {
continue
}
required[key] = struct{}{}
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; confirmed {
continue
}
if _, pending := pendingKeys[key]; pending {
continue
}
stream.NextCheckpointBlobRequestID++
if stream.NextCheckpointBlobRequestID == 0 {
stream.NextCheckpointBlobRequestID++
}
requestID := stream.NextCheckpointBlobRequestID
stream.PendingCheckpointBlobWrites[requestID] = key
pendingKeys[key] = struct{}{}
toWrite = append(toWrite, pendingCheckpointBlobWrite{requestID: requestID, blob: blob})
}
stream.PendingCheckpoint = &pendingCheckpointPublish{
State: state,
Required: required,
Completion: clonePendingTurnCompletion(completion),
}
if completion != nil {
stream.Phase = TurnPhaseCheckpointing
}
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
for _, write := range toWrite {
if err := service.broker.Publish(stream.RequestID, StreamEvent{
Message: buildSetCheckpointBlobMessage(write.requestID, write.blob),
}); err != nil {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("publish checkpoint blob: %w", err))
}
}
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
service.scheduleStreamTimer(
stream,
providerTimerKey(streamTimerCheckpointBlobs, ""),
checkpointBlobWriteTimeout,
streamTimerCheckpointBlobs,
"",
0,
"checkpoint blob write timeout",
)
return nil
}
func (service *Service) checkpointProjectionReady(stream *ActiveStream) bool {
if stream == nil {
return false
}
stream.mu.Lock()
defer stream.mu.Unlock()
if stream.PendingCheckpoint == nil {
return false
}
for key := range stream.PendingCheckpoint.Required {
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
return false
}
}
return true
}
func (service *Service) handleCheckpointBlobResult(stream *ActiveStream, message *agentv1.KvClientMessage) error {
if service == nil || stream == nil || message == nil || message.GetSetBlobResult() == nil {
return nil
}
stream.mu.Lock()
key, ok := stream.PendingCheckpointBlobWrites[message.GetId()]
if ok {
delete(stream.PendingCheckpointBlobWrites, message.GetId())
}
required := false
if ok && stream.PendingCheckpoint != nil {
_, required = stream.PendingCheckpoint.Required[key]
}
if ok && message.GetSetBlobResult().GetError() == nil {
stream.ConfirmedCheckpointBlobs[key] = struct{}{}
}
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
if !ok {
return nil
}
if blobErr := message.GetSetBlobResult().GetError(); blobErr != nil && required {
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf(
"client rejected checkpoint blob %s: %s",
hex.EncodeToString([]byte(key)),
firstNonEmpty(strings.TrimSpace(blobErr.GetMessage()), "unknown error"),
))
}
if service.checkpointProjectionReady(stream) {
return service.publishReadyCheckpoint(stream)
}
return nil
}
func (service *Service) publishReadyCheckpoint(stream *ActiveStream) error {
if service == nil || stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
if pending == nil {
stream.mu.Unlock()
return nil
}
for key := range pending.Required {
if _, confirmed := stream.ConfirmedCheckpointBlobs[key]; !confirmed {
stream.mu.Unlock()
return nil
}
}
stream.PendingCheckpoint = nil
state := pending.State
completion := clonePendingTurnCompletion(pending.Completion)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
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 completion != nil {
return service.finishSuccessfulTurnAfterCheckpoint(stream, *completion)
}
return nil
}
func (service *Service) handleCheckpointBlobTimeout(stream *ActiveStream) error {
if stream == nil {
return nil
}
stream.mu.Lock()
pendingCount := len(stream.PendingCheckpointBlobWrites)
stream.mu.Unlock()
return service.finishAfterCheckpointSyncFailure(stream, fmt.Errorf("%d checkpoint blob writes timed out", pendingCount))
}
func (service *Service) finishAfterCheckpointSyncFailure(stream *ActiveStream, cause error) error {
if stream == nil {
return nil
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
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)
}
return nil
}
func (service *Service) discardPendingCheckpoint(stream *ActiveStream, reason string) {
if stream == nil {
return
}
stream.mu.Lock()
stream.PendingCheckpoint = nil
stream.PendingCheckpointBlobWrites = make(map[uint32]string)
stream.UpdatedAt = time.Now().UTC()
stream.mu.Unlock()
clearStreamTimer(stream, providerTimerKey(streamTimerCheckpointBlobs, ""))
if strings.TrimSpace(reason) != "" {
log.Printf("forwarder pending checkpoint discarded request_id=%s conversation_id=%s reason=%s", stream.RequestID, stream.ConversationID, strings.TrimSpace(reason))
}
}
@@ -0,0 +1,208 @@
package forwarder
import (
"testing"
"google.golang.org/protobuf/encoding/protojson"
"cursor/gen/agentv1"
)
func TestCheckpointBlobSyncPublishesCheckpointAfterAcknowledgements(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) {
t.Fatalf("events before ACK = %d, want %d Blob writes", len(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)
}
}
acknowledgeCheckpointBlobs(t, service, stream)
events = readCheckpointTestEvents(t, service, stream)
checkpoint := events[len(events)-1].Message.GetConversationCheckpointUpdate()
if checkpoint == nil || len(checkpoint.GetTurns()) != 1 {
t.Fatalf("last event checkpoint = %#v, want one Blob-backed turn", checkpoint)
}
}
func TestCheckpointBlobSyncPublishesCheckpointBeforeSuccessfulTerminal(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
completion := &pendingTurnCompletion{
RequestID: stream.RequestID,
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
}
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
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 TestCheckpointBlobTimeoutDoesNotFailSuccessfulTurn(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
completion := &pendingTurnCompletion{
RequestID: stream.RequestID,
Usage: turnUsageSnapshot{InputTokens: 11, OutputTokens: 7},
}
if err := service.queueCheckpointProjection(stream, projection, completion); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
if err := service.handleCheckpointBlobTimeout(stream); err != nil {
t.Fatalf("handleCheckpointBlobTimeout() error = %v", err)
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, turnEnded, successfulEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
turnEnded = turnEnded || event.Message.GetInteractionUpdate().GetTurnEnded() != nil
successfulEnd = successfulEnd || event.End && event.TerminalErrorCode == ""
}
if checkpoint || !turnEnded || !successfulEnd {
t.Fatalf("timeout events checkpoint=%v turn_ended=%v successful_end=%v", checkpoint, turnEnded, successfulEnd)
}
}
func TestCancellationDiscardsPendingCheckpointAndIgnoresLateAcknowledgements(t *testing.T) {
service, stream, projection := testCheckpointBlobProjection(t)
if err := service.queueCheckpointProjection(stream, projection, nil); err != nil {
t.Fatalf("queueCheckpointProjection() error = %v", err)
}
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
requestIDs = append(requestIDs, requestID)
}
stream.mu.Unlock()
if err := service.handleCancelIntent(InboundIntent{
Kind: "cancel",
RequestID: stream.RequestID,
CancelReason: "user stopped",
}); err != nil {
t.Fatalf("handleCancelIntent() error = %v", err)
}
for _, requestID := range requestIDs {
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("late ACK %d error = %v", requestID, err)
}
}
events := readCheckpointTestEvents(t, service, stream)
var checkpoint, canceledEnd bool
for _, event := range events {
checkpoint = checkpoint || event.Message.GetConversationCheckpointUpdate() != nil
canceledEnd = canceledEnd || event.End && event.TerminalErrorCode == "canceled"
}
stream.mu.Lock()
pending := stream.PendingCheckpoint
stream.mu.Unlock()
if checkpoint || !canceledEnd || pending != nil {
t.Fatalf("cancel events checkpoint=%v canceled_end=%v pending=%v", checkpoint, canceledEnd, pending != nil)
}
}
func testCheckpointBlobProjection(t *testing.T) (*Service, *ActiveStream, *CheckpointProjection) {
t.Helper()
broker := NewStreamBroker()
service := &Service{
store: NewConversationFileStore(t.TempDir()),
projector: NewHistoryProjector(),
broker: broker,
}
stream, err := broker.OpenStream(
"request-1", "conversation-1", 1, "default", "default",
agentv1.AgentMode_AGENT_MODE_AGENT, "hello",
)
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
testCheckpointUserEntry(t),
newAssistantTextEntry(1, "request-1", "hi", "", ""),
},
}
projection, err := service.projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
if err := service.replaceCheckpointConversation(stream, conversation); err != nil {
t.Fatalf("replaceCheckpointConversation() error = %v", err)
}
return service, stream, projection
}
func testCheckpointUserEntry(t *testing.T) HistoryEntry {
t.Helper()
payload, err := protojson.Marshal(&agentv1.UserMessage{Text: "hello", MessageId: "message-1"})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
return HistoryEntry{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: payload}
}
func acknowledgeCheckpointBlobs(t *testing.T, service *Service, stream *ActiveStream) {
t.Helper()
for {
stream.mu.Lock()
requestIDs := make([]uint32, 0, len(stream.PendingCheckpointBlobWrites))
for requestID := range stream.PendingCheckpointBlobWrites {
requestIDs = append(requestIDs, requestID)
}
stream.mu.Unlock()
if len(requestIDs) == 0 {
return
}
for _, requestID := range requestIDs {
if err := service.handleCheckpointBlobResult(stream, &agentv1.KvClientMessage{
Id: requestID,
Message: &agentv1.KvClientMessage_SetBlobResult{
SetBlobResult: &agentv1.SetBlobResult{},
},
}); err != nil {
t.Fatalf("handleCheckpointBlobResult(%d) error = %v", requestID, err)
}
}
}
}
func readCheckpointTestEvents(t *testing.T, service *Service, stream *ActiveStream) []StreamEvent {
t.Helper()
events, err := service.broker.ReadFromCursor(stream.RequestID, 0)
if err != nil {
t.Fatalf("ReadFromCursor() error = %v", err)
}
return events
}
+16
View File
@@ -245,6 +245,22 @@ func buildCheckpointMessage(state *agentv1.ConversationStateStructure) *agentv1.
} }
} }
func buildSetCheckpointBlobMessage(id uint32, blob CheckpointBlob) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{
Message: &agentv1.AgentServerMessage_KvServerMessage{
KvServerMessage: &agentv1.KvServerMessage{
Id: id,
Message: &agentv1.KvServerMessage_SetBlobArgs{
SetBlobArgs: &agentv1.SetBlobArgs{
BlobId: append([]byte(nil), blob.ID...),
BlobData: append([]byte(nil), blob.Data...),
},
},
},
},
}
}
// buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。 // buildExecAbortMessage 构造对客户端执行桥的 abort 控制消息。
func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage { func buildExecAbortMessage(pending runtimecore.PendingExec) *agentv1.AgentServerMessage {
return &agentv1.AgentServerMessage{ return &agentv1.AgentServerMessage{
+236 -159
View File
@@ -2,6 +2,7 @@
package forwarder package forwarder
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
@@ -19,6 +20,51 @@ const projectedConversationMaxTokens = 130000
type HistoryProjector struct { type HistoryProjector struct {
} }
type CheckpointBlob struct {
ID []byte
Data []byte
}
type CheckpointProjection struct {
State *agentv1.ConversationStateStructure
Blobs []CheckpointBlob
}
type checkpointBlobGraph struct {
blobs map[[sha256.Size]byte][]byte
order [][sha256.Size]byte
}
func newCheckpointBlobGraph() *checkpointBlobGraph {
return &checkpointBlobGraph{blobs: make(map[[sha256.Size]byte][]byte)}
}
func (graph *checkpointBlobGraph) add(data []byte) []byte {
if graph == nil || len(data) == 0 {
return nil
}
id := sha256.Sum256(data)
if _, exists := graph.blobs[id]; !exists {
graph.blobs[id] = append([]byte(nil), data...)
graph.order = append(graph.order, id)
}
return append([]byte(nil), id[:]...)
}
func (graph *checkpointBlobGraph) list() []CheckpointBlob {
if graph == nil || len(graph.order) == 0 {
return nil
}
blobs := make([]CheckpointBlob, 0, len(graph.order))
for _, id := range graph.order {
blobs = append(blobs, CheckpointBlob{
ID: append([]byte(nil), id[:]...),
Data: append([]byte(nil), graph.blobs[id]...),
})
}
return blobs
}
// NewHistoryProjector 创建 history 投影器。 // NewHistoryProjector 创建 history 投影器。
func NewHistoryProjector() *HistoryProjector { func NewHistoryProjector() *HistoryProjector {
return &HistoryProjector{} return &HistoryProjector{}
@@ -475,6 +521,16 @@ func isHistoricalReplayToolResult(conversation *ConversationFile, entry HistoryE
// ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。 // ProjectLegacyCheckpoint 按需从 JSON history 投影出兼容旧客户端的 checkpoint 结构。
func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) { func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *ConversationFile) (*agentv1.ConversationStateStructure, error) {
projection, err := projector.ProjectCheckpointProjection(conversation)
if err != nil || projection == nil {
return nil, err
}
return projection.State, nil
}
// ProjectCheckpointProjection 同时返回 checkpoint 状态及其引用的内容寻址 Blob。
func (projector *HistoryProjector) ProjectCheckpointProjection(conversation *ConversationFile) (*CheckpointProjection, error) {
blobs := newCheckpointBlobGraph()
state := &agentv1.ConversationStateStructure{ state := &agentv1.ConversationStateStructure{
TokenDetails: &agentv1.ConversationTokenDetails{ TokenDetails: &agentv1.ConversationTokenDetails{
UsedTokens: conversationTokenDetailsUsedTokens(conversation), UsedTokens: conversationTokenDetailsUsedTokens(conversation),
@@ -488,7 +544,7 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
if conversation == nil { if conversation == nil {
mode := agentv1.AgentMode_AGENT_MODE_AGENT mode := agentv1.AgentMode_AGENT_MODE_AGENT
state.Mode = &mode state.Mode = &mode
return state, nil return &CheckpointProjection{State: state}, nil
} }
mode, err := parseModeAlias(conversation.Mode) mode, err := parseModeAlias(conversation.Mode)
if err != nil { if err != nil {
@@ -506,158 +562,11 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
if structuredState.HasTodos { if structuredState.HasTodos {
state.Todos = encodeConversationTodoBytes(structuredState.Todos) state.Todos = encodeConversationTodoBytes(structuredState.Todos)
} }
grouped := make(map[int64][]HistoryEntry) turnIDs, err := projectCheckpointTurnBlobs(conversation, blobs)
order := make([]int64, 0, conversation.NextTurnSeq) if err != nil {
for _, entry := range checkpointProjectionEntries(conversation.Entries) { return nil, err
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
for _, turnSeq := range order {
entries := grouped[turnSeq]
var rawUserMessage []byte
var turnRequestID string
steps := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil {
return nil, err
}
rawUserMessage = payload
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepPayload, err := marshalThinkingStep(payload.ReasoningContent)
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepPayload, err := proto.Marshal(&agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{
ToolCall: toolCall,
},
})
if err != nil {
return nil, err
}
steps = append(steps, stepPayload)
}
}
if len(rawUserMessage) == 0 && len(steps) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: rawUserMessage,
Steps: steps,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
state.Turns = append(state.Turns, turnPayload)
} }
state.Turns = turnIDs
replayMessages, err := projector.ProjectPromptReplay(conversation) replayMessages, err := projector.ProjectPromptReplay(conversation)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -685,15 +594,183 @@ func (projector *HistoryProjector) ProjectLegacyCheckpoint(conversation *Convers
return nil, err return nil, err
} }
state.RootPromptMessagesJson = rootPromptMessages state.RootPromptMessagesJson = rootPromptMessages
return state, nil return &CheckpointProjection{State: state, Blobs: blobs.list()}, nil
} }
func marshalThinkingStep(text string) ([]byte, error) { func projectCheckpointTurnBlobs(conversation *ConversationFile, blobs *checkpointBlobGraph) ([][]byte, error) {
return proto.Marshal(&agentv1.ConversationStep{ if conversation == nil || blobs == nil {
Message: &agentv1.ConversationStep_ThinkingMessage{ return nil, nil
ThinkingMessage: &agentv1.ThinkingMessage{Text: text}, }
}, grouped := make(map[int64][]HistoryEntry)
}) order := make([]int64, 0, conversation.NextTurnSeq)
for _, entry := range checkpointProjectionEntries(conversation.Entries) {
if entry.TurnSeq <= 0 {
continue
}
if _, ok := grouped[entry.TurnSeq]; !ok {
order = append(order, entry.TurnSeq)
}
grouped[entry.TurnSeq] = append(grouped[entry.TurnSeq], entry)
}
turnIDs := make([][]byte, 0, len(order))
for _, turnSeq := range order {
entries := grouped[turnSeq]
var userMessageID []byte
var turnRequestID string
stepIDs := make([][]byte, 0, len(entries))
seenToolCalls := make(map[string]struct{})
openToolCalls := make(map[string]struct{})
for _, entry := range entries {
if turnRequestID == "" {
turnRequestID = strings.TrimSpace(entry.RequestID)
}
switch strings.TrimSpace(entry.Kind) {
case "user_message":
userMessage := &agentv1.UserMessage{}
if err := protojson.Unmarshal(entry.Payload, userMessage); err != nil {
return nil, fmt.Errorf("decode checkpoint user_message: %w", err)
}
payload, err := proto.Marshal(userMessage)
if err != nil {
return nil, err
}
userMessageID = blobs.add(payload)
case "assistant_text":
var payload assistantTextPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.Text) == "" && strings.TrimSpace(payload.ReasoningContent) != "" && len(openToolCalls) > 0 {
continue
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if strings.TrimSpace(payload.Text) == "" {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_AssistantMessage{
AssistantMessage: &agentv1.AssistantMessage{Text: strings.TrimSpace(payload.Text)},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
case "tool_call":
var payload toolCallEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
seenToolCalls[toolCallID] = struct{}{}
openToolCalls[toolCallID] = struct{}{}
}
case "tool_result":
var payload toolResultEntryPayload
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
return nil, err
}
if toolCallID := strings.TrimSpace(payload.ToolCallID); toolCallID != "" {
if _, ok := seenToolCalls[toolCallID]; ok {
delete(openToolCalls, toolCallID)
continue
}
}
if strings.TrimSpace(payload.ReasoningContent) != "" {
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ThinkingMessage{
ThinkingMessage: &agentv1.ThinkingMessage{Text: payload.ReasoningContent},
},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
if len(payload.ToolCall) == 0 {
continue
}
toolCall := &agentv1.ToolCall{}
if err := protojson.Unmarshal(payload.ToolCall, toolCall); err != nil {
return nil, err
}
if !shouldPersistToolResultName(firstNonEmpty(strings.TrimSpace(payload.ToolName), inferToolName(toolCall))) {
continue
}
stepID, err := addCheckpointStepBlob(blobs, &agentv1.ConversationStep{
Message: &agentv1.ConversationStep_ToolCall{ToolCall: toolCall},
})
if err != nil {
return nil, err
}
stepIDs = append(stepIDs, stepID)
}
}
if len(userMessageID) == 0 && len(stepIDs) == 0 {
continue
}
agentTurn := &agentv1.AgentConversationTurnStructure{
UserMessage: userMessageID,
Steps: stepIDs,
}
if turnRequestID != "" {
agentTurn.RequestId = &turnRequestID
}
turnPayload, err := proto.Marshal(&agentv1.ConversationTurnStructure{
Turn: &agentv1.ConversationTurnStructure_AgentConversationTurn{
AgentConversationTurn: agentTurn,
},
})
if err != nil {
return nil, err
}
turnIDs = append(turnIDs, blobs.add(turnPayload))
}
return turnIDs, nil
}
func addCheckpointStepBlob(blobs *checkpointBlobGraph, step *agentv1.ConversationStep) ([]byte, error) {
payload, err := proto.Marshal(step)
if err != nil {
return nil, err
}
return blobs.add(payload), nil
} }
func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 { func conversationTokenDetailsUsedTokens(conversation *ConversationFile) uint32 {
@@ -0,0 +1,140 @@
package forwarder
import (
"crypto/sha256"
"strings"
"testing"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"cursor/gen/agentv1"
)
func TestProjectCheckpointProjectionBuildsResolvableForkState(t *testing.T) {
userPayload, err := protojson.Marshal(&agentv1.UserMessage{
Text: "parent question",
MessageId: "message-1",
})
if err != nil {
t.Fatalf("marshal user message: %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: userPayload},
newAssistantTextEntry(1, "request-1", "parent answer", "", ""),
},
}
projection, err := NewHistoryProjector().ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("ProjectCheckpointProjection() error = %v", err)
}
state := projection.State
if len(state.GetTurns()) != 1 {
t.Fatalf("ProjectCheckpointProjection() turns = %d, want 1 Blob-backed turn", len(state.GetTurns()))
}
blobs := make(map[string][]byte, len(projection.Blobs))
for _, blob := range projection.Blobs {
digest := sha256.Sum256(blob.Data)
if len(blob.ID) != sha256.Size || string(blob.ID) != string(digest[:]) {
t.Fatalf("invalid content-addressed Blob id=%x", blob.ID)
}
blobs[string(blob.ID)] = blob.Data
}
turnPayload, ok := blobs[string(state.GetTurns()[0])]
if !ok {
t.Fatal("turn references a missing Blob")
}
turn := &agentv1.ConversationTurnStructure{}
if err := proto.Unmarshal(turnPayload, turn); err != nil {
t.Fatalf("decode turn Blob: %v", err)
}
agentTurn := turn.GetAgentConversationTurn()
if agentTurn == nil {
t.Fatal("turn Blob does not contain an agent turn")
}
if _, ok := blobs[string(agentTurn.GetUserMessage())]; !ok {
t.Fatal("turn references a missing user message Blob")
}
for _, stepID := range agentTurn.GetSteps() {
if _, ok := blobs[string(stepID)]; !ok {
t.Fatal("turn references a missing step Blob")
}
}
messages, err := importedConversationStateModelMessages(state)
if err != nil {
t.Fatalf("importedConversationStateModelMessages() error = %v", err)
}
if len(messages) != 2 {
t.Fatalf("imported messages = %d, want parent user and assistant context", len(messages))
}
if messages[0].Role != "user" || !strings.Contains(messages[0].Content, "parent question") {
t.Fatalf("first imported message = %#v", messages[0])
}
if messages[1].Role != "assistant" || messages[1].Content != "parent answer" {
t.Fatalf("second imported message = %#v", messages[1])
}
}
func TestProjectCheckpointProjectionKeepsForkPointIsolatedFromLaterHistory(t *testing.T) {
firstUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "first question", MessageId: "message-1"})
if err != nil {
t.Fatalf("marshal first user message: %v", err)
}
conversation := &ConversationFile{
ConversationID: "conversation-1",
RootConversationID: "conversation-1",
Mode: "agent",
NextTurnSeq: 2,
NextEntrySeq: 3,
TokenDetailsMaxTokens: projectedConversationMaxTokens,
Entries: []HistoryEntry{
{Seq: 1, TurnSeq: 1, RequestID: "request-1", Role: "user", Kind: "user_message", Payload: firstUser},
newAssistantTextEntry(1, "request-1", "first answer", "", ""),
},
}
projector := NewHistoryProjector()
midpoint, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("midpoint projection: %v", err)
}
secondUser, err := protojson.Marshal(&agentv1.UserMessage{Text: "second question", MessageId: "message-2"})
if err != nil {
t.Fatalf("marshal second user message: %v", err)
}
appendEntriesInPlace(conversation, []HistoryEntry{
{TurnSeq: 2, RequestID: "request-2", Role: "user", Kind: "user_message", Payload: secondUser},
newAssistantTextEntry(2, "request-2", "second answer", "", ""),
})
latest, err := projector.ProjectCheckpointProjection(conversation)
if err != nil {
t.Fatalf("latest projection: %v", err)
}
midpointMessages, err := importedConversationStateModelMessages(midpoint.State)
if err != nil {
t.Fatalf("import midpoint messages: %v", err)
}
latestMessages, err := importedConversationStateModelMessages(latest.State)
if err != nil {
t.Fatalf("import latest messages: %v", err)
}
if len(midpoint.State.GetTurns()) != 1 || len(midpointMessages) != 2 {
t.Fatalf("midpoint turns=%d messages=%d, want 1 turn and 2 messages", len(midpoint.State.GetTurns()), len(midpointMessages))
}
if len(latest.State.GetTurns()) != 2 || len(latestMessages) != 4 {
t.Fatalf("latest turns=%d messages=%d, want 2 turns and 4 messages", len(latest.State.GetTurns()), len(latestMessages))
}
if midpointMessages[1].Content != "first answer" || latestMessages[3].Content != "second answer" {
t.Fatalf("fork snapshots are not isolated: midpoint=%#v latest=%#v", midpointMessages, latestMessages)
}
}
+21 -12
View File
@@ -858,9 +858,7 @@ func (service *Service) handleCancelIntent(intent InboundIntent) error {
}) })
} }
if hasCheckpoint { if hasCheckpoint {
if err := service.publishCheckpoint(stream.RequestID, stream.ConversationID); err != nil { service.discardPendingCheckpoint(stream, "checkpoint superseded by cancellation")
return err
}
} }
clearPendingProviderCompletion(stream) clearPendingProviderCompletion(stream)
stream.mu.Lock() stream.mu.Lock()
@@ -2122,9 +2120,15 @@ func (service *Service) completeSuccessfulTurn(stream *ActiveStream, completion
err, err,
) )
} }
if err := service.publishCheckpoint(requestID, conversationID); err != nil { return service.publishCheckpointWithCompletion(requestID, conversationID, &completion)
return err }
func (service *Service) finishSuccessfulTurnAfterCheckpoint(stream *ActiveStream, completion pendingTurnCompletion) error {
if stream == nil {
return nil
} }
requestID := firstNonEmpty(strings.TrimSpace(completion.RequestID), strings.TrimSpace(stream.RequestID))
usage := completion.Usage
if err := service.broker.Publish(requestID, StreamEvent{ if err := service.broker.Publish(requestID, StreamEvent{
Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens), Message: buildTurnEndedMessage(usage.InputTokens, usage.OutputTokens, usage.CacheReadTokens, usage.CacheWriteTokens),
}); err != nil { }); err != nil {
@@ -2151,7 +2155,11 @@ func (service *Service) failStreamIfNonTerminal(stream *ActiveStream, terminalCo
} }
// publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。 // publishCheckpoint 按当前内存会话镜像投影出 checkpoint,并广播给所有 RunSSE 订阅者。
func (service *Service) publishCheckpoint(requestID string, _ string) error { func (service *Service) publishCheckpoint(requestID string, conversationID string) error {
return service.publishCheckpointWithCompletion(requestID, conversationID, nil)
}
func (service *Service) publishCheckpointWithCompletion(requestID string, _ string, completion *pendingTurnCompletion) error {
stream, ok := service.broker.Get(requestID) stream, ok := service.broker.Get(requestID)
if !ok || stream == nil { if !ok || stream == nil {
return fmt.Errorf("request is not active: %s", requestID) return fmt.Errorf("request is not active: %s", requestID)
@@ -2160,15 +2168,16 @@ func (service *Service) publishCheckpoint(requestID string, _ string) error {
if err != nil { if err != nil {
return err return err
} }
state, err := service.projector.ProjectLegacyCheckpoint(conversation) projection, err := service.projector.ProjectCheckpointProjection(conversation)
if err != nil { if err != nil {
return err return err
} }
state.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions) if projection == nil || projection.State == nil {
service.rewriteCheckpointTokenDetailsForClient(stream, conversation, state) return fmt.Errorf("checkpoint projection is empty")
return service.broker.Publish(requestID, StreamEvent{ }
Message: buildCheckpointMessage(state), projection.State.PendingToolCalls = buildPendingToolCalls(pendingExecs, pendingInteractions)
}) service.rewriteCheckpointTokenDetailsForClient(stream, conversation, projection.State)
return service.queueCheckpointProjection(stream, projection, completion)
} }
func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) { func (service *Service) rewriteCheckpointTokenDetailsForClient(stream *ActiveStream, conversation *ConversationFile, state *agentv1.ConversationStateStructure) {
+10
View File
@@ -163,6 +163,10 @@ type ActiveStream struct {
ProviderUsage turnUsageSnapshot ProviderUsage turnUsageSnapshot
ProviderTerminalToolInvocation bool ProviderTerminalToolInvocation bool
PendingCompaction *PendingCompaction PendingCompaction *PendingCompaction
PendingCheckpointBlobWrites map[uint32]string
ConfirmedCheckpointBlobs map[string]struct{}
NextCheckpointBlobRequestID uint32
PendingCheckpoint *pendingCheckpointPublish
Backlog []StreamEvent Backlog []StreamEvent
Subscribers map[string]*StreamSubscriber Subscribers map[string]*StreamSubscriber
@@ -219,6 +223,12 @@ type pendingTurnCompletion struct {
Disposition pendingCompletionDisposition Disposition pendingCompletionDisposition
} }
type pendingCheckpointPublish struct {
State *agentv1.ConversationStateStructure
Required map[string]struct{}
Completion *pendingTurnCompletion
}
type PendingCompaction struct { type PendingCompaction struct {
Trigger string Trigger string
ContextTokens int64 ContextTokens int64
+1
View File
@@ -9,4 +9,5 @@ Tg群组:
https://t.me/cursor_byok https://t.me/cursor_byok
- 支持cursor-cli - 支持cursor-cli
- 修复对话中错误可能导致的消失问题