Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
158 lines
6.5 KiB
Go
158 lines
6.5 KiB
Go
package agentruntime
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
|
|
)
|
|
|
|
// appServerReviewTarget maps the `/review` slash-command args onto a codex
|
|
// app-server `review/start` target (see ReviewTarget in the app-server schema).
|
|
//
|
|
// The GUI review picker emits unambiguous colon-delimited forms so a human's
|
|
// free-form instructions can never be misread as a structured choice:
|
|
//
|
|
// (empty) -> uncommittedChanges
|
|
// uncommitted -> uncommittedChanges (review picker default)
|
|
// base:<branch> -> baseBranch
|
|
// commit:<sha> -> commit
|
|
// custom:<text> -> custom
|
|
// <anything else> -> custom (backward compatible free-form prompt)
|
|
//
|
|
// Git ref names disallow ':', so "base:"/"commit:"/"custom:" cannot collide
|
|
// with a real branch name, and free text such as "base our error handling"
|
|
// (no colon) stays a custom review.
|
|
func appServerReviewTarget(args string) map[string]any {
|
|
args = strings.TrimSpace(args)
|
|
if args == "" {
|
|
return map[string]any{"type": "uncommittedChanges"}
|
|
}
|
|
if strings.EqualFold(args, "uncommitted") {
|
|
return map[string]any{"type": "uncommittedChanges"}
|
|
}
|
|
if keyword, rest, ok := strings.Cut(args, ":"); ok {
|
|
rest = strings.TrimSpace(rest)
|
|
switch strings.ToLower(strings.TrimSpace(keyword)) {
|
|
case "base":
|
|
if rest != "" {
|
|
return map[string]any{"type": "baseBranch", "branch": rest}
|
|
}
|
|
case "commit":
|
|
if rest != "" {
|
|
return map[string]any{"type": "commit", "sha": rest}
|
|
}
|
|
case "custom":
|
|
if rest != "" {
|
|
return map[string]any{"type": "custom", "instructions": rest}
|
|
}
|
|
}
|
|
}
|
|
return map[string]any{"type": "custom", "instructions": args}
|
|
}
|
|
|
|
func appServerReviewReasoningSummary() string {
|
|
return "auto"
|
|
}
|
|
|
|
// appServerMessageHandler builds the message callback shared by the
|
|
// thread-control slash commands (compact, review, undo): every app-server
|
|
// message is routed through handleAppServerMessage and its events emitted.
|
|
// It begins the Provider input-unit tracker from the dispatch context so
|
|
// checkpoint observations emitted during an in-flight RPC (or before the
|
|
// session-level handler is restored) still receive transport positions —
|
|
// without this, compact turn/started can update canonical state while
|
|
// SemanticRuntime never sees a trigger match.
|
|
func (a *CodexAppServerAdapter) appServerMessageHandler(
|
|
appSession *codexAppServerSession,
|
|
session Session,
|
|
turnID string,
|
|
normalizer *acpTurnNormalizer,
|
|
emitEvents func([]activityshared.Event),
|
|
emitCommands CommandSnapshotSink,
|
|
) func(context.Context, acpMessage) error {
|
|
return func(ctx context.Context, message acpMessage) error {
|
|
endInputUnit := a.inputUnits.begin(ctx, session.AgentSessionID)
|
|
defer endInputUnit()
|
|
next, err := a.handleAppServerMessage(ctx, appSession.client, session, turnID, message, normalizer, emitEvents, emitCommands)
|
|
emitEvents(next)
|
|
return err
|
|
}
|
|
}
|
|
|
|
// execReviewSlashCommand starts a codex review for the parsed target and
|
|
// streams the review turn to completion, mirroring a normal turn's terminal
|
|
// handling. It always reports the command as handled.
|
|
func (a *CodexAppServerAdapter) execReviewSlashCommand(
|
|
ctx context.Context,
|
|
appSession *codexAppServerSession,
|
|
session Session,
|
|
args string,
|
|
turnID string,
|
|
appTurn *codexAppServerActiveTurn,
|
|
normalizer *acpTurnNormalizer,
|
|
emitEvents func([]activityshared.Event),
|
|
emitTerminal func([]activityshared.Event),
|
|
emitCommands CommandSnapshotSink,
|
|
reportDispatch ProviderDispatchSink,
|
|
admitProviderTurn func(string) error,
|
|
) (bool, error) {
|
|
normalizer.SetThinkingPresentation("review-process")
|
|
params := map[string]any{
|
|
"threadId": appSession.threadID,
|
|
"target": appServerReviewTarget(args),
|
|
"delivery": "inline",
|
|
// Inline review interleaves readable reasoning via summaryTextDelta.
|
|
// Request summaries explicitly so review turns do not inherit a thread
|
|
// configured with model_reasoning_summary=none (for example spark).
|
|
"summary": appServerReviewReasoningSummary(),
|
|
}
|
|
result, err := appSession.client.ReviewStart(ctx, params,
|
|
a.appServerMessageHandler(appSession, session, turnID, normalizer, emitEvents, emitCommands))
|
|
if err != nil {
|
|
reportCodexDispatchFailure(reportDispatch, err)
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, errPermissionRequestCanceled) {
|
|
terminalEvents := a.pendingRequestFailureEvents(session, turnID, errPermissionRequestCanceled)
|
|
terminalEvents = append(terminalEvents, normalizer.FinishInterrupted(session, turnID, "interrupted")...)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", map[string]any{
|
|
"error": err.Error(),
|
|
}))
|
|
emitTerminal(terminalEvents)
|
|
} else {
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(err)))
|
|
emitTerminal(terminalEvents)
|
|
}
|
|
return true, nil
|
|
}
|
|
initialTurn := appServerTurnFromResult(result)
|
|
if providerTurnID := asString(initialTurn["id"]); providerTurnID != "" {
|
|
a.setSessionActiveTurnID(session.AgentSessionID, appTurn, providerTurnID)
|
|
}
|
|
if err := admitProviderTurn(asString(initialTurn["id"])); err != nil {
|
|
return true, err
|
|
}
|
|
finalTurn, finishErr := a.awaitTurnCompletion(ctx, appSession, appTurn, initialTurn)
|
|
if finishErr != nil {
|
|
if errors.Is(finishErr, context.Canceled) || errors.Is(finishErr, errPermissionRequestCanceled) {
|
|
// User cancellation: resolve any pending approval as failed and
|
|
// report the turn as canceled (not failed), mirroring a normal turn.
|
|
terminalEvents := a.pendingRequestFailureEvents(session, turnID, errPermissionRequestCanceled)
|
|
terminalEvents = append(terminalEvents, normalizer.FinishInterrupted(session, turnID, "interrupted")...)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnCanceled, turnID, SessionStatusCanceled, "", "", map[string]any{
|
|
"error": finishErr.Error(),
|
|
}))
|
|
emitTerminal(terminalEvents)
|
|
} else {
|
|
terminalEvents := normalizer.FinishFailed(session, turnID)
|
|
terminalEvents = append(terminalEvents, newTurnActivityEvent(session, EventTurnFailed, turnID, SessionStatusFailed, "", "", acpFailureMetadata(finishErr)))
|
|
emitTerminal(terminalEvents)
|
|
}
|
|
return true, nil
|
|
}
|
|
normalizer.ApplyAssistantTurnFinalText(appServerTurnFinalAssistantText(finalTurn))
|
|
emitTerminal(appServerTurnTerminalEvents(session, turnID, finalTurn, normalizer))
|
|
return true, nil
|
|
}
|