Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/qa/scenarios/RT-010.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ area: RT
title: Create session
persona: Bruno
journey: J-17
expected: `POST /api/sessions` with `{workspace|workspace_path, agent_name, provider?, model?, reasoning_effort?, name?, network_participation?}` validates the target, returns a durable `starting` session with an immutable Local/Live snapshot, then becomes `active` or durably `stopped` with startup diagnostics; invalid admission requests fail without a catalog row.
expected: `POST /api/sessions` with `{workspace|workspace_path, agent_name, prompt?, provider?, model?, reasoning_effort?, name?, network_participation?}` validates the target, durably queues a non-empty first prompt before returning the `starting` session with an immutable Local/Live snapshot, then dispatches that prompt exactly once after activation or a later resume; invalid admission and prompt-queue failures leave no session residue.
entry_points: HTTP+UDS `POST /api/sessions`; web session-create-dialog
qa_status: untested
bug_ids: BUG-0002; BUG-0003
Expand Down Expand Up @@ -33,3 +33,5 @@ Task43 QA 2026-07-08: scenario covered by persona charter plus required E2E lane
Model-selector QA 2026-07-10: charter walked; live/manual, structured-surface, and official E2E evidence passed.

QA impact 2026-07-22: public creation now accepts durably before ACP startup and reports background startup failures on the session record. Runtime fields may all be omitted to inherit project defaults. Reset to untested.

QA impact 2026-07-25: create may atomically stage the first prompt before the `201`; activation or resume dispatches it exactly once under the selected immutable runtime. Reset to untested; flag only.
4 changes: 3 additions & 1 deletion docs/qa/scenarios/RT-063.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ area: RT
title: Session-create via unified runtime selector
persona: Bruno
journey: J-17
expected: The trigger renders the agent's workspace-scoped effective provider·model·reasoning; submitting untouched omits all runtime fields to preserve project inheritance, while an explicit selector change sends one coherent session override.
expected: The composer trigger renders the agent's workspace-scoped effective provider·model·reasoning without a button-like border or background; sending an untouched runtime omits all runtime fields to preserve project inheritance, while an explicit selector change atomically creates the session with one coherent runtime override and the required first prompt.
entry_points: web session-create dialog; POST /api/sessions
qa_status: untested
bug_ids: BUG-0024
Expand All @@ -21,3 +21,5 @@ story: model-selector task_02 — unified RuntimeSelector replaces the three lea
Model-selector QA 2026-07-10: charter walked; live/manual, structured-surface, and official E2E evidence passed. BUG-0024 is fixed and retested in the uncommitted worktree; no SHA exists by explicit user direction.

QA impact 2026-07-22: session-create now consumes `effective_runtime` and distinguishes untouched inheritance from explicit overrides. Reset to untested.

QA impact 2026-07-25: RuntimeSelector moved into the first-message composer; Send is the sole creation action and the daemon owns deferred first-prompt dispatch. Reset to untested; flag only.
4 changes: 3 additions & 1 deletion docs/qa/scenarios/RT-072.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ area: RT
title: Claude max reasoning via the web selector
persona: Bruno
journey: J-21
expected: A Claude session created through the web selector with reasoning_effort=max applies the advertised ACP effort after model selection and before the first prompt (browser-use highest-risk flow).
expected: A Claude session created through the web composer with reasoning_effort=max applies the advertised ACP effort after model selection and before the atomically queued first prompt is dispatched exactly once (browser-use highest-risk flow).
entry_points: web session-create to Claude to max; browser-use:browser
qa_status: untested
bug_ids: BUG-0024
Expand All @@ -21,3 +21,5 @@ story: model-selector task_04 highest-risk browser-use flow; execution captures
Model-selector QA 2026-07-10: charter walked; live/manual, structured-surface, and official E2E evidence passed. BUG-0024 is fixed and retested in the uncommitted worktree; no SHA exists by explicit user direction.

2026-07-22 selector redesign: reasoning is now picked on a slider (no None or Default stops; the model default renders preselected while the wire value stays empty). Max is the last stop. Flagged untested for the next QA cycle.

QA impact 2026-07-25: the composer sends the first prompt and max-reasoning override in one create request; the daemon applies runtime overrides before deferred prompt dispatch. Reset to untested; flag only.
4 changes: 3 additions & 1 deletion docs/qa/scenarios/RT-new-session-fast-feedback.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ area: RT
title: Start a new session with immediate truthful feedback
persona: Bruno
journey: J-17
expected: Clicking New session gives visible feedback within 100 ms, navigates to one durable `starting` session within 250 ms, keeps the composer disabled until `active`, and surfaces a durable startup failure without duplicate creation; runtime-related failures link to runtime settings, while unrelated startup failures do not.
expected: Clicking New session opens a first-message composer; Send gives visible feedback within 100 ms, atomically queues the prompt, navigates to one durable `starting` session within 250 ms, and surfaces a durable startup failure without duplicate creation; the queued prompt remains available for explicit resume and is never posted a second time by the web client.
entry_points: web agent detail New session; web Agents Start session
qa_status: untested
bug_ids: BUG-20260713-cursor-model-startup-contract, BUG-20260713-new-session-modal-lingers, BUG-20260713-first-prompt-optimistic-stuck, BUG-20260713-stop-generation-local-stuck
Expand Down Expand Up @@ -43,3 +43,5 @@ verified.
QA impact 2026-07-22: session admission is now durable and asynchronous, the Web polls `starting`
at 500 ms, and startup failure has an explicit recovery pane. Reset to untested for a fresh timing
and failure-path replay.

QA impact 2026-07-25: first-message creation is now atomic: the `201 starting` session already owns the queued prompt, navigation remains immediate, and resume delivers a prompt retained across startup failure. Reset to untested; flag only.
40 changes: 40 additions & 0 deletions internal/api/contract/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,34 @@ func TestCreateSessionRequestJSONShape(t *testing.T) {
}
})

t.Run("Should round-trip the optional initial prompt", func(t *testing.T) {
t.Parallel()

req := contract.CreateSessionRequest{
AgentName: "coder",
Workspace: "alpha",
Prompt: "Investigate the failing build",
}
raw, err := json.Marshal(req)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
var decoded contract.CreateSessionRequest
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if decoded.Prompt != req.Prompt {
t.Fatalf("decoded.Prompt = %q, want %q", decoded.Prompt, req.Prompt)
}
var shape map[string]any
if err := json.Unmarshal(raw, &shape); err != nil {
t.Fatalf("json.Unmarshal(map) error = %v", err)
}
if shape["prompt"] != req.Prompt {
t.Fatalf("shape = %#v, want prompt key %q", shape, req.Prompt)
}
})

t.Run("Should omit model and reasoning_effort cleanly when absent", func(t *testing.T) {
t.Parallel()

Expand All @@ -624,6 +652,18 @@ func TestCreateSessionRequestJSONShape(t *testing.T) {
t.Fatalf("raw = %s", string(raw))
}
})

t.Run("Should omit prompt cleanly when absent", func(t *testing.T) {
t.Parallel()

raw, err := json.Marshal(contract.CreateSessionRequest{AgentName: "coder", Workspace: "alpha"})
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if strings.Contains(string(raw), "prompt") {
t.Fatalf("raw = %s, want prompt omitted", string(raw))
}
})
}

func TestMemoryV2PublicContractJSONShape(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions internal/api/contract/session_runtime_payloads.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type CreateSessionRequest struct {
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ReasoningEffort ReasoningEffort `json:"reasoning_effort,omitempty"`
Prompt string `json:"prompt,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
WorkspacePath string `json:"workspace_path,omitempty"`
Expand Down
5 changes: 4 additions & 1 deletion internal/api/core/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ func (h *BaseHandlers) CreateSession(c *gin.Context) {
)
return
}
info, err := h.SessionAcceptance.CreateAccepted(c.Request.Context(), opts)
info, err := h.SessionAcceptance.CreateAccepted(c.Request.Context(), session.CreateAcceptedOpts{
Session: opts,
InitialPrompt: strings.TrimSpace(req.Prompt),
})
if err != nil {
h.respondError(c, StatusForSessionError(err), err)
return
Expand Down
30 changes: 15 additions & 15 deletions internal/api/core/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,27 +46,20 @@ func TestBaseHandlersSessionEndpoints(t *testing.T) {
ListAllFn: func(context.Context) ([]*session.Info, error) {
return []*session.Info{testutil.NewSessionInfo("sess-a")}, nil
},
CreateFn: func(_ context.Context, opts session.CreateOpts) (*session.Session, error) {
createCalled.Store(true)
if opts.AgentName != "coder" ||
opts.Provider != "fake" ||
opts.Workspace != "alpha" ||
opts.Type != session.SessionTypeUser {
t.Fatalf("Create opts = %#v", opts)
}
created := testutil.NewSession("sess-created")
created.AgentName = opts.AgentName
created.Provider = opts.Provider
return created, nil
},
CreateAcceptedFn: func(_ context.Context, opts session.CreateOpts) (*session.Info, error) {
CreateAcceptedFn: func(_ context.Context, acceptedOpts session.CreateAcceptedOpts) (*session.Info, error) {
createCalled.Store(true)
opts := acceptedOpts.Session
if opts.AgentName != "coder" ||
opts.Provider != "fake" ||
opts.Model != "fake-pro" ||
opts.ReasoningEffort != "high" ||
opts.Workspace != "alpha" ||
opts.Type != session.SessionTypeUser {
t.Fatalf("CreateAccepted opts = %#v", opts)
}
if acceptedOpts.InitialPrompt != "Investigate the failing build" {
t.Fatalf("CreateAccepted initial prompt = %q", acceptedOpts.InitialPrompt)
}
created := testutil.NewSessionInfo("sess-created")
created.AgentName = opts.AgentName
created.Provider = opts.Provider
Expand Down Expand Up @@ -190,7 +183,14 @@ func TestBaseHandlersSessionEndpoints(t *testing.T) {
fixture.Engine,
http.MethodPost,
"/sessions",
[]byte(`{"agent_name":"coder","provider":"fake","workspace":"alpha"}`),
[]byte(`{
"agent_name":"coder",
"provider":"fake",
"model":"fake-pro",
"reasoning_effort":"high",
"workspace":"alpha",
"prompt":" Investigate the failing build "
}`),
)
if createResp.Code != http.StatusCreated || !createCalled.Load() {
t.Fatalf("create status = %d, called=%v", createResp.Code, createCalled.Load())
Expand Down
2 changes: 1 addition & 1 deletion internal/api/core/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ type SessionManager interface {
// SessionAcceptanceManager durably accepts user-created sessions without
// waiting for provider startup.
type SessionAcceptanceManager interface {
CreateAccepted(ctx context.Context, opts session.CreateOpts) (*session.Info, error)
CreateAccepted(ctx context.Context, opts session.CreateAcceptedOpts) (*session.Info, error)
}

// DaemonDrainController owns daemon-global new-work admission state.
Expand Down
14 changes: 11 additions & 3 deletions internal/api/testutil/session_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package testutil

import (
"context"
"errors"
"strings"

"github.com/compozy/agh/internal/acp"
core "github.com/compozy/agh/internal/api/core"
Expand All @@ -13,7 +15,7 @@ import (

type StubSessionManager struct {
CreateFn func(context.Context, session.CreateOpts) (*session.Session, error)
CreateAcceptedFn func(context.Context, session.CreateOpts) (*session.Info, error)
CreateAcceptedFn func(context.Context, session.CreateAcceptedOpts) (*session.Info, error)
ListFn func() []*session.Info
ListAllFn func(context.Context) ([]*session.Info, error)
ListPageFn func(context.Context, session.ListQuery) (session.ListPage, error)
Expand Down Expand Up @@ -56,11 +58,17 @@ func (s StubSessionManager) Create(ctx context.Context, opts session.CreateOpts)
return nil, session.ErrSessionNotFound
}

func (s StubSessionManager) CreateAccepted(ctx context.Context, opts session.CreateOpts) (*session.Info, error) {
func (s StubSessionManager) CreateAccepted(
ctx context.Context,
opts session.CreateAcceptedOpts,
) (*session.Info, error) {
if s.CreateAcceptedFn != nil {
return s.CreateAcceptedFn(ctx, opts)
}
created, err := s.Create(ctx, opts)
if strings.TrimSpace(opts.InitialPrompt) != "" {
return nil, errors.New("testutil: CreateAcceptedFn is required for an initial prompt")
}
created, err := s.Create(ctx, opts.Session)
if created == nil || err != nil {
return nil, err
}
Expand Down
64 changes: 64 additions & 0 deletions internal/api/udsapi/transport_parity_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
apispec "github.com/compozy/agh/internal/api/spec"
automationpkg "github.com/compozy/agh/internal/automation"
aghconfig "github.com/compozy/agh/internal/config"
"github.com/compozy/agh/internal/session"
"github.com/compozy/agh/internal/store"
"github.com/compozy/agh/internal/testutil/acpmock"
e2etest "github.com/compozy/agh/internal/testutil/e2e"
Expand Down Expand Up @@ -529,6 +530,7 @@ func TestUDSTransportSessionProviderCreateReadMatchesHTTP(t *testing.T) {
AgentName: transportUDSAutomationAgent,
Provider: provider,
WorkspacePath: runtimeHarness.WorkspaceRoot,
Prompt: "Continue delegated task run",
}, &created); err != nil {
t.Fatalf("UDS create session error = %v", err)
}
Expand All @@ -538,6 +540,18 @@ func TestUDSTransportSessionProviderCreateReadMatchesHTTP(t *testing.T) {
if created.Session.Provider != provider {
t.Fatalf("UDS create provider = %q, want %q", created.Session.Provider, provider)
}
if created.Session.State != session.StateStarting {
t.Fatalf("UDS accepted create state = %q, want %q", created.Session.State, session.StateStarting)
}
created.Session = waitForTransportSessionActive(t, ctx, runtimeHarness, created.Session)
waitForTransportTranscriptText(
t,
ctx,
runtimeHarness,
created.Session.ID,
"Continue delegated task run",
"Delegated task session responded.",
)

var udsDetail aghcontract.SessionResponse
if err := runtimeHarness.UDSJSON(
Expand Down Expand Up @@ -1886,6 +1900,56 @@ func joinTransportTranscript(messages []transcriptpkg.UIMessage) string {
return transcriptpkg.JoinUIMessageText(messages)
}

func waitForTransportTranscriptText(
t testing.TB,
ctx context.Context,
runtimeHarness *e2etest.RuntimeHarness,
sessionID string,
want ...string,
) {
t.Helper()

waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
var (
lastText string
lastErr error
)
for {
page, err := runtimeHarness.SessionTranscript(waitCtx, sessionID)
if err == nil {
lastText = joinTransportTranscript(transcriptpkg.MessagesFromEntries(page.Entries))
matched := true
for _, expected := range want {
if !strings.Contains(lastText, expected) {
matched = false
break
}
}
if matched {
return
}
} else {
lastErr = err
}

select {
case <-waitCtx.Done():
t.Fatalf(
"timed out waiting for session %q transcript text %q: %v; last error=%v transcript=%q",
sessionID,
want,
waitCtx.Err(),
lastErr,
lastText,
)
case <-ticker.C:
}
}
}

func transportMockFixturePath(t testing.TB, name string) string {
t.Helper()

Expand Down
34 changes: 21 additions & 13 deletions internal/cli/session_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,39 @@ import (
"github.com/spf13/cobra"
)

const sessionCreateExample = ` # Start a session in the current workspace using the configured default agent
agh session new

# Start a named session for a specific registered workspace and agent
agh session new --workspace checkout-api --agent reviewer --name review-api

# Override provider, model, and reasoning effort for this session only
agh session new --provider codex --model gpt-5.6-sol --reasoning-effort max

# Create the session and dispatch its first prompt after runtime activation
agh session new --prompt "Inspect the failing build and propose a fix"

# Auto-register an absolute workspace path before creating the session
agh session new --cwd "$PWD" --agent reviewer`

func newSessionCreateCommand(deps commandDeps) *cobra.Command {
var (
agentName string
cwd string
name string
provider string
model string
prompt string
reasoningEffort string
workspaceRef string
noWait bool
networkFlags networkParticipationFlags
)

cmd := &cobra.Command{
Use: sessionNewKey,
Short: "Create a new session",
Example: ` # Start a session in the current workspace using the configured default agent
agh session new

# Start a named session for a specific registered workspace and agent
agh session new --workspace checkout-api --agent reviewer --name review-api

# Override provider, model, and reasoning effort for this session only
agh session new --provider codex --model gpt-5.6-sol --reasoning-effort max

# Auto-register an absolute workspace path before creating the session
agh session new --cwd "$PWD" --agent reviewer`,
Use: sessionNewKey,
Short: "Create a new session",
Example: sessionCreateExample,
RunE: func(cmd *cobra.Command, _ []string) error {
client, err := clientFromDeps(deps)
if err != nil {
Expand All @@ -54,6 +60,7 @@ func newSessionCreateCommand(deps commandDeps) *cobra.Command {
Provider: strings.TrimSpace(provider),
Model: strings.TrimSpace(model),
ReasoningEffort: contract.ReasoningEffort(strings.TrimSpace(reasoningEffort)),
Prompt: strings.TrimSpace(prompt),
Name: name,
Workspace: workspace,
WorkspacePath: workspacePath,
Expand All @@ -79,6 +86,7 @@ func newSessionCreateCommand(deps commandDeps) *cobra.Command {
bindNamedNetworkParticipationFlags(cmd, &networkFlags)
cmd.Flags().StringVar(&provider, sessionProviderKey, "", "Optional provider override for this session")
cmd.Flags().StringVar(&model, "model", "", "Optional model override for this session")
cmd.Flags().StringVar(&prompt, "prompt", "", "Initial prompt to dispatch after the session becomes active")
cmd.Flags().BoolVar(&noWait, "no-wait", false, "Return after the durable starting session is accepted")
cmd.Flags().StringVar(
&reasoningEffort,
Expand Down
Loading
Loading