mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #13706 from esengine/a9/acp-readiness-pause
fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
This commit is contained in:
5 files changed
+84
-5
No files matched your search
@@ -188,6 +188,15 @@ error, not with a stop reason:
|
||||
`data.code` is the identity a client keys on; `message` is the host's
|
||||
description of the cause, clipped to 2048 bytes.
|
||||
|
||||
### When the host pauses a turn
|
||||
|
||||
A turn that stops before delivery is verified (for example, files were written and no
|
||||
verification command ran) is not a failure and not a completion:
|
||||
|
||||
- `session/prompt` returns `stopReason: "end_turn"`;
|
||||
- the response carries the typed outcome in `_meta["reasonix.io"].turnOutcome`, e.g. `{"kind": "paused", "reason": "…"}`;
|
||||
- the streamed text ends with a host-written `[paused] Not verified, …` line, so a client that reads only message text does not take the work for done.
|
||||
|
||||
## Mid-turn steering extension
|
||||
|
||||
Reasonix exposes mid-turn guidance as an ACP v1 vendor extension. It is not a
|
||||
|
||||
@@ -552,14 +552,14 @@ type StopReason string
|
||||
const (
|
||||
StopEndTurn StopReason = "end_turn"
|
||||
StopCancelled StopReason = "cancelled"
|
||||
StopError StopReason = "error"
|
||||
)
|
||||
|
||||
// SessionPromptResult ends a session/prompt. TranscriptPath is reserved for a
|
||||
// future on-disk transcript pointer; omitted (null) for now.
|
||||
type SessionPromptResult struct {
|
||||
StopReason StopReason `json:"stopReason"`
|
||||
TranscriptPath *string `json:"transcriptPath,omitempty"`
|
||||
StopReason StopReason `json:"stopReason"`
|
||||
TranscriptPath *string `json:"transcriptPath,omitempty"`
|
||||
Meta map[string]any `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// session/update (agent → client notifications)
|
||||
|
||||
@@ -2159,6 +2159,61 @@ func TestServePromptFailureIsAJSONRPCErrorWithTypedCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeReadinessPauseEndsTheTurnWithAReceiptAndTypedOutcome(t *testing.T) {
|
||||
factory := &fakeFactory{behavior: func(_ context.Context, sink event.Sink, _ string) error {
|
||||
sink.Emit(event.Event{Kind: event.Text, Text: "all done"})
|
||||
return &agent.FinalReadinessError{Attempts: 1, Reason: "run a relevant verification command", Missing: []string{"verification"}}
|
||||
}}
|
||||
client, stop := startServer(t, factory)
|
||||
defer stop()
|
||||
|
||||
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
|
||||
newResp := client.call(t, "session/new", SessionNewParams{})
|
||||
var nr SessionNewResult
|
||||
json.Unmarshal(newResp.Result, &nr)
|
||||
|
||||
promptCh := client.callAsync("session/prompt", SessionPromptParams{
|
||||
SessionID: nr.SessionID,
|
||||
Prompt: []ContentBlock{{Type: "text", Text: "write something"}},
|
||||
})
|
||||
notifs, resp := drainPrompt(t, client, promptCh)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("a readiness pause is not a failure: %+v", resp.Error)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp.Result, &raw); err != nil {
|
||||
t.Fatalf("prompt result: %v", err)
|
||||
}
|
||||
if string(raw["stopReason"]) != `"end_turn"` {
|
||||
t.Errorf("stopReason = %s, want end_turn", raw["stopReason"])
|
||||
}
|
||||
var meta struct {
|
||||
Reasonix struct {
|
||||
TurnOutcome ReasonixTurnOutcome `json:"turnOutcome"`
|
||||
} `json:"reasonix.io"`
|
||||
}
|
||||
if err := json.Unmarshal(raw["_meta"], &meta); err != nil || meta.Reasonix.TurnOutcome.Kind != "paused" {
|
||||
t.Errorf("_meta = %s (%v), want turnOutcome kind paused", raw["_meta"], err)
|
||||
}
|
||||
var text strings.Builder
|
||||
for _, n := range notifs {
|
||||
var p struct {
|
||||
Update struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
Content struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
} `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(n.Params, &p) == nil && p.Update.SessionUpdate == "agent_message_chunk" {
|
||||
text.WriteString(p.Update.Content.Text)
|
||||
}
|
||||
}
|
||||
if got := text.String(); !strings.Contains(got, "all done") || !strings.Contains(got, "[paused]") || !strings.Contains(got, "verification") {
|
||||
t.Errorf("streamed text = %q, want the answer followed by a [paused] receipt naming the missing verification", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRejectsConcurrentPromptForSameSession(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
@@ -327,13 +327,16 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
|
||||
sess.persistAfterTurn(text)
|
||||
|
||||
stop := StopEndTurn
|
||||
var meta map[string]any
|
||||
if runErr != nil {
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
switch {
|
||||
case runCtx.Err() != nil:
|
||||
stop = StopCancelled
|
||||
case errors.As(runErr, &readinessErr):
|
||||
stop = StopError
|
||||
outcome := ReasonixTurnOutcome{Kind: "paused", Reason: clipStatusText(readinessErr.Error(), 2_048)}
|
||||
sess.sink.send(readinessReceipt(outcome.Reason, readinessErr.Missing))
|
||||
meta = map[string]any{"reasonix.io": map[string]any{"turnOutcome": outcome}}
|
||||
default:
|
||||
return nil, &RPCError{
|
||||
Code: ErrInternal,
|
||||
@@ -342,7 +345,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
|
||||
}
|
||||
}
|
||||
}
|
||||
res := SessionPromptResult{StopReason: stop}
|
||||
res := SessionPromptResult{StopReason: stop, Meta: meta}
|
||||
if sess.transcript != "" {
|
||||
res.TranscriptPath = &sess.transcript
|
||||
}
|
||||
@@ -681,3 +684,13 @@ func sessionInfoMatchesCwd(info SessionInfo, filter string) bool {
|
||||
}
|
||||
return filepath.Clean(info.Cwd) == filepath.Clean(filter)
|
||||
}
|
||||
|
||||
// readinessReceipt is the host-written line a text-only client sees when the
|
||||
// turn stopped before delivery was verified; the typed outcome rides _meta.
|
||||
func readinessReceipt(reason string, missing []string) messageChunk {
|
||||
text := "\n\n[paused] Not verified, the work may be incomplete: " + reason
|
||||
if len(missing) > 0 {
|
||||
text += " (missing: " + strings.Join(redactStatusTexts(missing, 256), ", ") + ")"
|
||||
}
|
||||
return messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(text)}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
- 变更:通过 ACP 连接时,因“尚未验证”被暂停的回合以 end_turn 结束,响应带 paused 结果,文本末尾追加 [paused] 回执。关联 #13706。
|
||||
- Changed: Over ACP, a turn paused as unverified now ends with end_turn, a paused outcome and a [paused] receipt at the end of the text. Refs #13706.
|
||||
Reference in new issue
Block a user