fix: node CLI adapter SSE 및 persistent execution 개선
This commit is contained in:
parent
afc063dc90
commit
c81efd4ff2
6 changed files with 409 additions and 34 deletions
|
|
@ -54,6 +54,21 @@ type profileSession struct {
|
|||
done <-chan error
|
||||
closeFn func() error
|
||||
mu sync.Mutex
|
||||
|
||||
tailMu sync.Mutex
|
||||
tail strings.Builder
|
||||
}
|
||||
|
||||
func (s *profileSession) appendTail(text string) {
|
||||
s.tailMu.Lock()
|
||||
defer s.tailMu.Unlock()
|
||||
appendBounded(&s.tail, text, 2048)
|
||||
}
|
||||
|
||||
func (s *profileSession) getTail() string {
|
||||
s.tailMu.Lock()
|
||||
defer s.tailMu.Unlock()
|
||||
return s.tail.String()
|
||||
}
|
||||
|
||||
type codexExecSession struct {
|
||||
|
|
|
|||
|
|
@ -300,6 +300,13 @@ func opencodeEventEnvelope(data []byte) (opencodeEnvelope, bool) {
|
|||
return *global.Payload, true
|
||||
}
|
||||
|
||||
// sseEvtTrace summarises one received SSE event for timeout/cancel diagnostics.
|
||||
type sseEvtTrace struct {
|
||||
Type string `json:"t"`
|
||||
SID string `json:"sid,omitempty"` // "ok", "mm" (mismatch), or ""
|
||||
Outcome string `json:"out,omitempty"` // "delta", "idle", "error", "skip", "filtered"
|
||||
}
|
||||
|
||||
func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec, sess *opencodeSSESession, opts opencodeRunOpts, body io.Reader, sink runtime.EventSink) error {
|
||||
type evt struct {
|
||||
ev opencodeEnvelope
|
||||
|
|
@ -335,12 +342,45 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
partTypes = map[string]string{}
|
||||
)
|
||||
|
||||
// Bounded ring buffer for cancel/timeout diagnostics.
|
||||
const traceSize = 8
|
||||
var (
|
||||
traceRing [traceSize]sseEvtTrace
|
||||
traceHead int
|
||||
traceSeen int
|
||||
)
|
||||
addTrace := func(entry sseEvtTrace) {
|
||||
traceRing[traceHead%traceSize] = entry
|
||||
traceHead++
|
||||
traceSeen++
|
||||
}
|
||||
recentTrace := func() []sseEvtTrace {
|
||||
n := traceHead
|
||||
if n > traceSize {
|
||||
n = traceSize
|
||||
}
|
||||
out := make([]sseEvtTrace, n)
|
||||
start := traceHead - n
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = traceRing[(start+i)%traceSize]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
finalize := func(reason error) error {
|
||||
bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if sess.sessionID != "" {
|
||||
_ = opencodeAbort(bg, sess.serverURL, sess.sessionID)
|
||||
}
|
||||
if c.logger != nil {
|
||||
c.logger.Warn("opencode sse run cancelled without completion",
|
||||
zap.String("run_id", spec.RunID),
|
||||
zap.String("reason", reason.Error()),
|
||||
zap.Int("total_events", traceSeen),
|
||||
zap.Any("recent_events", recentTrace()),
|
||||
)
|
||||
}
|
||||
_ = sink.Emit(context.Background(), runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeCancelled,
|
||||
|
|
@ -373,8 +413,13 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
|
||||
// Filter events that carry a sessionID mismatching ours, when present.
|
||||
if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" && sess.sessionID != "" && sid != sess.sessionID {
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: "mm", Outcome: "filtered"})
|
||||
continue
|
||||
}
|
||||
sidStatus := ""
|
||||
if sid, ok := stringFromProp(e.ev.Properties, "sessionID"); ok && sid != "" {
|
||||
sidStatus = "ok"
|
||||
}
|
||||
|
||||
switch e.ev.Type {
|
||||
case "session.next.text.delta":
|
||||
|
|
@ -387,6 +432,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
Delta: delta,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"})
|
||||
} else {
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
|
||||
}
|
||||
case "message.part.updated":
|
||||
if partID, partType, ok := opencodePartInfo(e.ev.Properties); ok {
|
||||
|
|
@ -400,6 +448,7 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
outputTokens = out
|
||||
}
|
||||
}
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
|
||||
case "message.part.delta":
|
||||
delta := opencodeMessagePartDeltaText(e.ev.Properties, partTypes)
|
||||
if delta != "" {
|
||||
|
|
@ -410,6 +459,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
Delta: delta,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "delta"})
|
||||
} else {
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
|
||||
}
|
||||
case "session.next.step.ended":
|
||||
if in, out, ok := opencodeStepTokens(e.ev.Properties); ok {
|
||||
|
|
@ -420,14 +472,19 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
outputTokens = out
|
||||
}
|
||||
}
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
|
||||
case "session.idle":
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"})
|
||||
return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens)
|
||||
case "session.status":
|
||||
if opencodeStatusIdle(e.ev.Properties) {
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "idle"})
|
||||
return emitOpencodeComplete(ctx, sink, spec.RunID, inputTokens, outputTokens)
|
||||
}
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
|
||||
case "session.error":
|
||||
msg := opencodeErrorMessage(e.ev.Properties)
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "error"})
|
||||
_ = sink.Emit(ctx, runtime.RuntimeEvent{
|
||||
RunID: spec.RunID,
|
||||
Type: runtime.EventTypeError,
|
||||
|
|
@ -449,6 +506,9 @@ func (c *CLI) driveOpencodeSSE(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
_ = opencodePermissionReply(bg, sess.serverURL, permID, reply)
|
||||
cancel()
|
||||
}
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "ok"})
|
||||
default:
|
||||
addTrace(sseEvtTrace{Type: e.ev.Type, SID: sidStatus, Outcome: "skip"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -506,8 +566,14 @@ func opencodeMessagePartDeltaText(props map[string]any, partTypes map[string]str
|
|||
return ""
|
||||
}
|
||||
partID, _ := props["partID"].(string)
|
||||
if partID == "" || partTypes[partID] != "text" {
|
||||
return ""
|
||||
// Only reject the delta when we positively know the part is not text type.
|
||||
// If message.part.updated was missed (e.g. second SSE connection opened after
|
||||
// the server already emitted it), partTypes has no entry and we allow the
|
||||
// delta rather than silently dropping it.
|
||||
if partID != "" {
|
||||
if pt, known := partTypes[partID]; known && pt != "text" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
delta, _ := props["delta"].(string)
|
||||
return delta
|
||||
|
|
|
|||
|
|
@ -583,6 +583,137 @@ func TestCLIExecuteOpencodeSSE_SessionErrorEmitsRuntimeError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOpencodeSSE_ConsecutiveExecutesReuseSession(t *testing.T) {
|
||||
fake, srv := newOpencodeFakeServer(t, "ses_reuse")
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"opencode": opencodeSSEProfile(srv.URL),
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
waitPromptCalls := func(t *testing.T, want int32) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for fake.promptCalls.Load() < want && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if fake.promptCalls.Load() < want {
|
||||
t.Fatalf("prompt_async call count: got %d, want >= %d", fake.promptCalls.Load(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- First Execute ---
|
||||
sink1 := &testutil.FakeSink{}
|
||||
done1 := make(chan error, 1)
|
||||
go func() {
|
||||
done1 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-seq1",
|
||||
Target: "opencode",
|
||||
Input: map[string]any{"prompt": "first"},
|
||||
}, sink1)
|
||||
}()
|
||||
|
||||
waitPromptCalls(t, 1)
|
||||
fake.push(map[string]any{
|
||||
"type": "session.next.text.delta",
|
||||
"properties": map[string]any{
|
||||
"sessionID": "ses_reuse",
|
||||
"delta": "IOP_E2E_OPENCODE_ONE",
|
||||
},
|
||||
})
|
||||
fake.push(map[string]any{
|
||||
"type": "session.idle",
|
||||
"properties": map[string]any{"sessionID": "ses_reuse"},
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-done1:
|
||||
if err != nil {
|
||||
t.Fatalf("first execute: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("first execute did not return")
|
||||
}
|
||||
|
||||
if combined := testutil.CollectDeltas(sink1.Events()); combined != "IOP_E2E_OPENCODE_ONE" {
|
||||
t.Errorf("run1 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_ONE")
|
||||
}
|
||||
if fake.createCalls.Load() != 1 {
|
||||
t.Errorf("after run1: expected 1 createSession call, got %d", fake.createCalls.Load())
|
||||
}
|
||||
|
||||
// --- Second Execute on same CLI / same session ---
|
||||
sink2 := &testutil.FakeSink{}
|
||||
done2 := make(chan error, 1)
|
||||
go func() {
|
||||
done2 <- c.Execute(context.Background(), noderuntime.ExecutionSpec{
|
||||
RunID: "run-seq2",
|
||||
Target: "opencode",
|
||||
Input: map[string]any{"prompt": "second"},
|
||||
}, sink2)
|
||||
}()
|
||||
|
||||
waitPromptCalls(t, 2)
|
||||
fake.push(map[string]any{
|
||||
"type": "session.next.text.delta",
|
||||
"properties": map[string]any{
|
||||
"sessionID": "ses_reuse",
|
||||
"delta": "IOP_E2E_OPENCODE_TWO",
|
||||
},
|
||||
})
|
||||
fake.push(map[string]any{
|
||||
"type": "session.idle",
|
||||
"properties": map[string]any{"sessionID": "ses_reuse"},
|
||||
})
|
||||
|
||||
select {
|
||||
case err := <-done2:
|
||||
if err != nil {
|
||||
t.Fatalf("second execute: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("second execute did not return")
|
||||
}
|
||||
|
||||
// Session was reused: still 1 create call, 2 prompt calls.
|
||||
if fake.createCalls.Load() != 1 {
|
||||
t.Errorf("after run2: expected 1 createSession call (reused), got %d", fake.createCalls.Load())
|
||||
}
|
||||
if fake.promptCalls.Load() != 2 {
|
||||
t.Errorf("expected 2 prompt_async calls, got %d", fake.promptCalls.Load())
|
||||
}
|
||||
|
||||
// run2 sink must have the second delta and a complete, not the first delta.
|
||||
if combined := testutil.CollectDeltas(sink2.Events()); combined != "IOP_E2E_OPENCODE_TWO" {
|
||||
t.Errorf("run2 deltas: got %q, want %q", combined, "IOP_E2E_OPENCODE_TWO")
|
||||
}
|
||||
for _, e := range sink2.Events() {
|
||||
if e.Type == noderuntime.EventTypeDelta && e.Delta == "IOP_E2E_OPENCODE_ONE" {
|
||||
t.Error("run2 sink must not contain run1 delta")
|
||||
}
|
||||
}
|
||||
var run1Complete, run2Complete bool
|
||||
for _, e := range sink1.Events() {
|
||||
if e.Type == noderuntime.EventTypeComplete {
|
||||
run1Complete = true
|
||||
}
|
||||
}
|
||||
for _, e := range sink2.Events() {
|
||||
if e.Type == noderuntime.EventTypeComplete {
|
||||
run2Complete = true
|
||||
}
|
||||
}
|
||||
if !run1Complete {
|
||||
t.Error("run1: expected complete event")
|
||||
}
|
||||
if !run2Complete {
|
||||
t.Error("run2: expected complete event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExecuteOpencodeSSE_ContextCancelAbortsSession(t *testing.T) {
|
||||
fake, srv := newOpencodeFakeServer(t, "ses_1")
|
||||
|
||||
|
|
|
|||
|
|
@ -154,8 +154,11 @@ func TestOpencodeMessagePartDeltaText(t *testing.T) {
|
|||
}{
|
||||
{"text part", map[string]any{"partID": "prt_text", "field": "text", "delta": "OK"}, "OK"},
|
||||
{"reasoning part ignored", map[string]any{"partID": "prt_reasoning", "field": "text", "delta": "thinking"}, ""},
|
||||
{"unknown part ignored", map[string]any{"partID": "prt_missing", "field": "text", "delta": "lost"}, ""},
|
||||
// Unknown part: message.part.updated may have been missed on a new SSE
|
||||
// connection; allow the delta rather than silently dropping it.
|
||||
{"unknown part allowed when field=text", map[string]any{"partID": "prt_missing", "field": "text", "delta": "allowed"}, "allowed"},
|
||||
{"non text field ignored", map[string]any{"partID": "prt_text", "field": "metadata", "delta": "lost"}, ""},
|
||||
{"empty partID field=text allowed", map[string]any{"partID": "", "field": "text", "delta": "bare"}, "bare"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := opencodeMessagePartDeltaText(tc.props, partTypes); got != tc.want {
|
||||
|
|
|
|||
|
|
@ -119,13 +119,8 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
return runtime.ErrRunCancelled
|
||||
case out, ok := <-sess.output:
|
||||
if !ok {
|
||||
// Process exited — remove the dead session.
|
||||
c.mu.Lock()
|
||||
if s, found := c.sessions[sess.key]; found && s == sess {
|
||||
delete(c.sessions, sess.key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
|
||||
err := drainPersistentDone(sess)
|
||||
return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err)
|
||||
}
|
||||
if delta := outputFilter.Filter(out.text); delta != "" {
|
||||
outputTokens += len(strings.Fields(delta))
|
||||
|
|
@ -203,16 +198,7 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
|
|||
Timestamp: time.Now(),
|
||||
})
|
||||
case err := <-sess.done:
|
||||
c.mu.Lock()
|
||||
if s, found := c.sessions[sess.key]; found && s == sess {
|
||||
delete(c.sessions, sess.key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
msg := "persistent session process exited"
|
||||
if err != nil {
|
||||
msg = fmt.Sprintf("persistent session process exited: %v", err)
|
||||
}
|
||||
return emitRuntimeError(ctx, sink, spec.RunID, msg)
|
||||
return c.emitPersistentExit(ctx, sink, spec.RunID, targetName, profile, sess, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +295,14 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
outputCh := make(chan cliOutput, 1024)
|
||||
doneCh := make(chan error, 1)
|
||||
|
||||
sess := &profileSession{
|
||||
key: key,
|
||||
name: key.target,
|
||||
profile: profile,
|
||||
output: outputCh,
|
||||
done: doneCh,
|
||||
}
|
||||
|
||||
var input io.Writer
|
||||
var closeFn func() error
|
||||
var cmd *exec.Cmd
|
||||
|
|
@ -327,12 +321,18 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
}
|
||||
input = ptmx
|
||||
closeFn = ptmx.Close
|
||||
sess.cmd = cmd
|
||||
sess.input = ptmx
|
||||
sess.closeFn = ptmx.Close
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := ptmx.Read(buf)
|
||||
if n > 0 {
|
||||
outputCh <- cliOutput{text: string(buf[:n])}
|
||||
text := string(buf[:n])
|
||||
sess.appendTail(text)
|
||||
outputCh <- cliOutput{text: text}
|
||||
}
|
||||
if err != nil {
|
||||
doneCh <- cmd.Wait()
|
||||
|
|
@ -361,11 +361,17 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
}
|
||||
input = stdin
|
||||
closeFn = stdin.Close
|
||||
sess.cmd = cmd
|
||||
sess.input = stdin
|
||||
sess.closeFn = stdin.Close
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
outputCh <- cliOutput{text: line + "\n", markerLine: line}
|
||||
text := line + "\n"
|
||||
sess.appendTail(text)
|
||||
outputCh <- cliOutput{text: text, markerLine: line}
|
||||
}
|
||||
doneCh <- cmd.Wait()
|
||||
close(outputCh)
|
||||
|
|
@ -382,29 +388,28 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
|
|||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
tail := sess.getTail()
|
||||
if err == nil {
|
||||
if tail != "" {
|
||||
return nil, fmt.Errorf("process exited during startup: %s", tail)
|
||||
}
|
||||
return nil, fmt.Errorf("process exited during startup")
|
||||
}
|
||||
if tail != "" {
|
||||
return nil, fmt.Errorf("process exited during startup: %w (recent output: %q)", err, tail)
|
||||
}
|
||||
return nil, fmt.Errorf("process exited during startup: %w", err)
|
||||
default:
|
||||
}
|
||||
|
||||
return &profileSession{
|
||||
key: key,
|
||||
name: key.target,
|
||||
profile: profile,
|
||||
cmd: cmd,
|
||||
input: input,
|
||||
output: outputCh,
|
||||
done: doneCh,
|
||||
closeFn: closeFn,
|
||||
}, nil
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
var startupBuf strings.Builder
|
||||
acceptedClaudeWorkspaceTrust := false
|
||||
acceptedClaudeBypassWarning := false
|
||||
for {
|
||||
select {
|
||||
|
|
@ -412,9 +417,19 @@ func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Dur
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
if profile.Terminal && !acceptedClaudeBypassWarning && out.text != "" {
|
||||
if profile.Terminal && out.text != "" {
|
||||
appendBounded(&startupBuf, out.text, 8192)
|
||||
if shouldAcceptClaudeBypassWarning(startupBuf.String()) {
|
||||
rawStr := startupBuf.String()
|
||||
|
||||
if !acceptedClaudeWorkspaceTrust && shouldAcceptClaudeWorkspaceTrust(rawStr) {
|
||||
if _, err := io.WriteString(input, "\r"); err != nil {
|
||||
logger.Warn("cli adapter: accept claude workspace trust", zap.String("target", key.target), zap.Error(err))
|
||||
} else {
|
||||
acceptedClaudeWorkspaceTrust = true
|
||||
logger.Info("cli adapter: accepted claude workspace trust", zap.String("target", key.target))
|
||||
startupBuf.Reset()
|
||||
}
|
||||
} else if !acceptedClaudeBypassWarning && shouldAcceptClaudeBypassWarning(rawStr) {
|
||||
if _, err := io.WriteString(input, "\x1b[B\r"); err != nil {
|
||||
logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err))
|
||||
} else {
|
||||
|
|
@ -447,6 +462,12 @@ func appendBounded(buf *strings.Builder, s string, max int) {
|
|||
buf.WriteString(raw[len(raw)-max:])
|
||||
}
|
||||
|
||||
func shouldAcceptClaudeWorkspaceTrust(raw string) bool {
|
||||
compact := compactTerminalText(raw)
|
||||
return strings.Contains(compact, "quicksafetycheckisthisaproject") &&
|
||||
strings.Contains(compact, "yesitrustthisfolder")
|
||||
}
|
||||
|
||||
func shouldAcceptClaudeBypassWarning(raw string) bool {
|
||||
compact := compactTerminalText(raw)
|
||||
return strings.Contains(compact, "claudecoderunninginbypasspermissionsmode") &&
|
||||
|
|
@ -496,3 +517,35 @@ func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, log
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drainPersistentDone(sess *profileSession) error {
|
||||
select {
|
||||
case err := <-sess.done:
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CLI) emitPersistentExit(ctx context.Context, sink runtime.EventSink, runID, targetName string, profile config.CLIProfileConf, sess *profileSession, err error) error {
|
||||
c.mu.Lock()
|
||||
if s, found := c.sessions[sess.key]; found && s == sess {
|
||||
delete(c.sessions, sess.key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
cmdSummary := fmt.Sprintf("%s %s", profile.Command, strings.Join(profile.Args, " "))
|
||||
tail := sess.getTail()
|
||||
|
||||
var msg string
|
||||
if err != nil {
|
||||
msg = fmt.Sprintf("persistent session process exited: %s, target=%s, session=%s, command=%q", err.Error(), targetName, sess.key.sessionID, cmdSummary)
|
||||
} else {
|
||||
msg = fmt.Sprintf("persistent session process exited unexpectedly: target=%s, session=%s, command=%q", targetName, sess.key.sessionID, cmdSummary)
|
||||
}
|
||||
|
||||
if tail != "" {
|
||||
msg = fmt.Sprintf("%s, recent output: %q", msg, tail)
|
||||
}
|
||||
return emitRuntimeError(ctx, sink, runID, msg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,16 +97,33 @@ func TestCLIExecutePersistentProcessExitReturnsError(t *testing.T) {
|
|||
t.Fatal("expected non-nil error when persistent process exits")
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
t.Logf("Got error: %q", errStr)
|
||||
if !strings.Contains(errStr, "before-exit") {
|
||||
t.Errorf("expected error to contain recent output 'before-exit', got %q", errStr)
|
||||
}
|
||||
if !strings.Contains(errStr, "exit status 2") {
|
||||
t.Errorf("expected error to contain exit status 'exit status 2', got %q", errStr)
|
||||
}
|
||||
if !strings.Contains(errStr, "target=exit-on-input") {
|
||||
t.Errorf("expected error to contain target=exit-on-input, got %q", errStr)
|
||||
}
|
||||
|
||||
events := sink.Events()
|
||||
var errorEvent noderuntime.RuntimeEvent
|
||||
var hasError bool
|
||||
for _, e := range events {
|
||||
if e.Type == noderuntime.EventTypeError {
|
||||
hasError = true
|
||||
errorEvent = e
|
||||
}
|
||||
}
|
||||
if !hasError {
|
||||
t.Fatal("expected error event in emitted events")
|
||||
}
|
||||
if !strings.Contains(errorEvent.Error, "before-exit") {
|
||||
t.Errorf("expected error event to contain recent output, got %q", errorEvent.Error)
|
||||
}
|
||||
if len(events) > 0 && events[len(events)-1].Type == noderuntime.EventTypeComplete {
|
||||
t.Fatal("last event should not be complete when process exits unexpectedly")
|
||||
}
|
||||
|
|
@ -263,12 +280,60 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning(t *testing.T) {
|
||||
testutil.RequirePTYSupport(t)
|
||||
if _, err := osexec.LookPath("stty"); err != nil {
|
||||
t.Skip("stty required")
|
||||
}
|
||||
|
||||
cfg := config.CLIConf{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.CLIProfileConf{
|
||||
"claude-trust-warning": {
|
||||
Command: os.Args[0],
|
||||
Args: []string{"-test.run=TestRawTUIHelperProcess", "--"},
|
||||
Env: []string{"IOP_CLAUDE_TRUST_WARNING_HELPER=1"},
|
||||
Persistent: true,
|
||||
Terminal: true,
|
||||
ResponseIdleTimeoutMS: 100,
|
||||
StartupIdleTimeoutMS: 50,
|
||||
},
|
||||
},
|
||||
}
|
||||
c := clipkg.New(cfg, zap.NewNop())
|
||||
|
||||
ctx := context.Background()
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Stop(ctx) }()
|
||||
|
||||
execCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sink := &testutil.FakeSink{}
|
||||
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
|
||||
RunID: "run-claude-trust-warning",
|
||||
Target: "claude-trust-warning",
|
||||
Input: map[string]any{"prompt": "hello"},
|
||||
}, sink)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "reply:hello") {
|
||||
t.Fatalf("expected reply:hello in deltas, got %q", combined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawTUIHelperProcess(t *testing.T) {
|
||||
switch {
|
||||
case os.Getenv("IOP_RAW_TUI_HELPER") == "1":
|
||||
runRawTUIHelper()
|
||||
case os.Getenv("IOP_CLAUDE_WARNING_HELPER") == "1":
|
||||
runClaudeWarningHelper()
|
||||
case os.Getenv("IOP_CLAUDE_TRUST_WARNING_HELPER") == "1":
|
||||
runClaudeTrustAndWarningHelper()
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
|
@ -319,6 +384,48 @@ func runClaudeWarningHelper() {
|
|||
readPromptAndReply()
|
||||
}
|
||||
|
||||
func runClaudeTrustAndWarningHelper() {
|
||||
setRawTerminal()
|
||||
fmt.Fprint(os.Stdout, "Quick safety check: Is this a project you created or one you trust?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n")
|
||||
_ = os.Stdout.Sync()
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if n > 0 && buf[0] == '\r' {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stdout, "WARNING: Claude Code running in BypassPermissions mode\r\n2. Yes, I accept\r\n")
|
||||
_ = os.Stdout.Sync()
|
||||
|
||||
want := []byte{'\x1b', '[', 'B', '\r'}
|
||||
seen := make([]byte, 0, len(want))
|
||||
for {
|
||||
n, err := os.Stdin.Read(buf)
|
||||
if n > 0 {
|
||||
seen = append(seen, buf[0])
|
||||
if len(seen) > len(want) {
|
||||
seen = seen[len(seen)-len(want):]
|
||||
}
|
||||
if string(seen) == string(want) {
|
||||
fmt.Fprint(os.Stdout, "ready\r\n")
|
||||
_ = os.Stdout.Sync()
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
readPromptAndReply()
|
||||
}
|
||||
|
||||
func readPromptAndReply() {
|
||||
var prompt []byte
|
||||
buf := make([]byte, 1)
|
||||
|
|
|
|||
Loading…
Reference in a new issue