Fix no-response hang; per-turn-resume chat; direct-to-chat UX + Stop
ROOT CAUSE of "no responses ever appear": Claude's streaming-input mode (stdin kept open) hangs when combined with --permission-prompt-tool — it emits init then produces nothing. Verified by reproduction: single-shot + approval server works; open-stdin + approval server hangs; open-stdin without the approval server works. Fix — conversational sessions now run one single-shot process PER TURN, with follow-ups via native `claude --resume <id>` carrying the message. Verified against real Claude: turn 1 "my word is ZORP" → turn 2 (resume) "what was it?" → "ZORP" (context retained). This is the only reliable path with approvals. - ResumeSpec carries a `prompt`; ClaudeCodeBackend.resume sends it. - SessionController `conversational` mode: a turn's terminal runFinished means awaitingInput (session stays alive); sendInput resumes (or starts the first turn). shutdown ends it. Single-shot/one-shot behavior unchanged for non-chat. - AppStore: sessions are conversational; createSession's prompt is optional (create-without-start), plus newSession(in:) for random-named chats. UX per request: - New chat goes straight to the chat interface with a random three-word name (RandomName, e.g. amber-quiet-otter); the worktree is created up front and the agent runs on the first message. Per-project "+" in the sidebar; sessions nest under their project/directory. - Agent Stop control (interrupt) in the session toolbar while a turn runs. Tests: conversational per-turn-resume SessionController test; AppStore tests updated for the awaiting-input-between-turns model. Full suite 80 green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -24,11 +24,12 @@ struct NucleicApp: App {
|
||||
_store = State(initialValue: AppStore(
|
||||
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir
|
||||
) { _ in
|
||||
// Interactive multi-turn session: keep stdin open so the user can
|
||||
// keep chatting (closeStdinAfterPrompt: false). Our approval server is
|
||||
// authoritative (--permission-mode default), child is hermetic
|
||||
// (--strict-mcp-config).
|
||||
ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: false))
|
||||
// Each chat turn is a single-shot run (stdin closed), with follow-ups
|
||||
// resumed via --resume. This is the only reliable way to use the
|
||||
// approval server: Claude's streaming-input mode hangs alongside
|
||||
// --permission-prompt-tool. Approval server is authoritative
|
||||
// (--permission-mode default); child is hermetic (--strict-mcp-config).
|
||||
ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: true))
|
||||
})
|
||||
} catch {
|
||||
fatalError("Could not open the Nucleic store at \(support.path): \(error)")
|
||||
|
||||
@@ -5,7 +5,6 @@ import NucleicCore
|
||||
/// from UX_MACOS.
|
||||
struct RootView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showingNewSession = false
|
||||
@State private var showingAddProject = false
|
||||
|
||||
var body: some View {
|
||||
@@ -13,17 +12,29 @@ struct RootView: View {
|
||||
NavigationSplitView {
|
||||
List(selection: $store.openSessionID) {
|
||||
if store.projects.isEmpty {
|
||||
Text("No projects yet").foregroundStyle(.secondary)
|
||||
Text("Add a project to begin").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(store.projects) { project in
|
||||
Section(project.name) {
|
||||
Section {
|
||||
let sessions = store.summaries(for: project.id)
|
||||
if sessions.isEmpty {
|
||||
Text("No sessions").font(.caption).foregroundStyle(.tertiary)
|
||||
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(sessions) { summary in
|
||||
SessionRow(summary: summary).tag(Optional(summary.id))
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text(project.name)
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await store.newSession(in: project) }
|
||||
} label: {
|
||||
Image(systemName: "plus.circle")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("New chat in \(project.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,23 +46,16 @@ struct RootView: View {
|
||||
Label("Add Project", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
ToolbarItem {
|
||||
Button { showingNewSession = true } label: {
|
||||
Label("New Session", systemImage: "plus")
|
||||
}
|
||||
.disabled(store.projects.isEmpty)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if store.openSessionID != nil {
|
||||
SessionDetailView()
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No session selected", systemImage: "sparkles",
|
||||
description: Text("Create a session to run an agent in an isolated worktree."))
|
||||
"No chat selected", systemImage: "bubble.left.and.bubble.right",
|
||||
description: Text("Pick a project in the sidebar and press + to start a chat."))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewSession) { NewSessionSheet() }
|
||||
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
|
||||
.overlay(alignment: .bottom) {
|
||||
if let error = store.lastError {
|
||||
|
||||
@@ -26,6 +26,12 @@ struct SessionDetailView: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
if isBusy {
|
||||
Button { Task { await store.interruptOpenSession() } } label: {
|
||||
Label("Stop", systemImage: "stop.circle")
|
||||
}
|
||||
.help("Interrupt the running turn")
|
||||
}
|
||||
Button { Task { await store.refreshOpenStatus() } } label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
||||
@@ -2,59 +2,6 @@ import SwiftUI
|
||||
import AppKit
|
||||
import NucleicCore
|
||||
|
||||
/// Create a session: pick a project, name it, give the agent a prompt
|
||||
/// (UX_MACOS "New-session sheet").
|
||||
struct NewSessionSheet: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var projectID: ProjectID?
|
||||
@State private var title = ""
|
||||
@State private var prompt = ""
|
||||
@State private var creating = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("New Session").font(.title2).bold()
|
||||
Picker("Project", selection: $projectID) {
|
||||
ForEach(store.projects) { project in
|
||||
Text(project.name).tag(Optional(project.id))
|
||||
}
|
||||
}
|
||||
TextField("Title (becomes the branch name)", text: $title)
|
||||
Text("Prompt").font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $prompt)
|
||||
.font(.body).frame(minHeight: 120)
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Create") { Task { await create() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(projectID == nil || title.isEmpty || prompt.isEmpty || creating)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 460)
|
||||
.onAppear { if projectID == nil { projectID = store.projects.first?.id } }
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
guard let projectID, let project = store.project(projectID) else { return }
|
||||
creating = true
|
||||
defer { creating = false }
|
||||
do {
|
||||
let id = try await store.createSession(in: project, title: title, prompt: prompt)
|
||||
store.openSessionID = id
|
||||
dismiss()
|
||||
} catch {
|
||||
store.lastError = "Create session failed: \(error)"
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a git repository as a project.
|
||||
struct AddProjectSheet: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
@@ -104,11 +104,27 @@ public final class AppStore {
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
|
||||
/// Create a worktree, open a transcript, spawn a controller, persist, and start
|
||||
/// the agent — the end-to-end "new session" flow (PLAN M1).
|
||||
/// Create a randomly-named chat under a project and open it. The session's
|
||||
/// worktree is created immediately; the agent doesn't run until the user sends
|
||||
/// the first message in the chat composer.
|
||||
@discardableResult
|
||||
public func newSession(in project: Project) async -> SessionID? {
|
||||
do {
|
||||
let id = try await createSession(in: project, title: RandomName.generate(), prompt: "")
|
||||
openSessionID = id
|
||||
return id
|
||||
} catch {
|
||||
lastError = "New session failed: \(error)"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a worktree, open a transcript, spawn a (conversational) controller, and
|
||||
/// persist. If `prompt` is non-empty the first turn starts immediately; otherwise
|
||||
/// the session waits for the user's first message (PLAN M1).
|
||||
@discardableResult
|
||||
public func createSession(
|
||||
in project: Project, title: String, prompt: String, base: GitRef? = nil
|
||||
in project: Project, title: String, prompt: String = "", base: GitRef? = nil
|
||||
) async throws -> SessionID {
|
||||
let sessionID = SessionID.generate()
|
||||
let backendID = project.defaultBackend ?? .claudeCode
|
||||
@@ -122,22 +138,25 @@ public final class AppStore {
|
||||
sessionID: sessionID, backend: backendID, worktree: worktree.path, createdAt: now())
|
||||
let writer = try TranscriptWriter(url: transcriptURL, header: header)
|
||||
|
||||
let trimmedPrompt = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let session = Session(
|
||||
id: sessionID, projectID: project.id, backend: backendID,
|
||||
title: title, status: .running,
|
||||
title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
|
||||
worktreePath: worktree.path, branch: worktree.branch, baseSHA: worktree.baseSHA,
|
||||
transcriptPath: transcriptURL.path, createdAt: now(), updatedAt: now())
|
||||
|
||||
let controller = SessionController(
|
||||
session: session, backend: backendFactory(session), transcript: writer,
|
||||
worktreeManager: worktrees, project: project, worktree: worktree,
|
||||
metadataStore: database, now: now)
|
||||
metadataStore: database, conversational: true, now: now)
|
||||
controllers[sessionID] = controller
|
||||
try? await database.saveSession(session)
|
||||
upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
|
||||
|
||||
observe(controller, sessionID)
|
||||
await controller.start(prompt: AgentInput(text: prompt))
|
||||
if !trimmedPrompt.isEmpty {
|
||||
await controller.start(prompt: AgentInput(text: trimmedPrompt))
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
|
||||
@@ -149,12 +149,18 @@ public struct ResumeSpec: Sendable {
|
||||
public let sessionID: SessionID
|
||||
public let backendSessionID: String
|
||||
public let worktree: WorktreePath
|
||||
/// The follow-up user message for this resumed turn (nil = re-attach only).
|
||||
public let prompt: AgentInput?
|
||||
public let fork: Bool
|
||||
|
||||
public init(sessionID: SessionID, backendSessionID: String, worktree: WorktreePath, fork: Bool = false) {
|
||||
public init(
|
||||
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
|
||||
prompt: AgentInput? = nil, fork: Bool = false
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
self.backendSessionID = backendSessionID
|
||||
self.worktree = worktree
|
||||
self.prompt = prompt
|
||||
self.fork = fork
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
let run = RunSpec(
|
||||
sessionID: resume.sessionID,
|
||||
worktree: resume.worktree,
|
||||
prompt: AgentInput(parts: []))
|
||||
prompt: resume.prompt ?? AgentInput(parts: []))
|
||||
return makeStream(run: run, resumeArgs: args)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,11 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
let stderrTail = StderrTail()
|
||||
let stderrTask = Task {
|
||||
for try await line in handle.stderrLines {
|
||||
stderrTail.append(String(decoding: line, as: UTF8.self))
|
||||
let text = String(decoding: line, as: UTF8.self)
|
||||
stderrTail.append(text)
|
||||
if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
|
||||
FileHandle.standardError.write(Data(("claude-stderr: " + text + "\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// Generates a friendly session name as three hyphenated dictionary words
|
||||
/// (e.g. `amber-quiet-otter`), used as both the display title and the worktree
|
||||
/// branch slug when the user creates a chat without naming it.
|
||||
public enum RandomName {
|
||||
private static let adjectives = [
|
||||
"amber", "brisk", "calm", "dapper", "eager", "fuzzy", "gentle", "hazy",
|
||||
"ivory", "jolly", "keen", "lucid", "mellow", "nimble", "olive", "plucky",
|
||||
"quiet", "rustic", "sleek", "tidy", "upbeat", "vivid", "warm", "zesty",
|
||||
"bold", "clever", "dusky", "frosty", "golden", "humble",
|
||||
]
|
||||
private static let textures = [
|
||||
"ancient", "breezy", "coral", "dewy", "ember", "feather", "glass", "harbor",
|
||||
"iris", "jade", "kelp", "lunar", "maple", "north", "opal", "pebble",
|
||||
"quartz", "river", "spruce", "thistle", "umber", "velvet", "willow", "yarn",
|
||||
"cedar", "drift", "fern", "grove", "meadow", "slate",
|
||||
]
|
||||
private static let animals = [
|
||||
"otter", "falcon", "lynx", "heron", "bison", "marten", "raven", "shrew",
|
||||
"tapir", "vole", "wren", "yak", "badger", "civet", "dingo", "egret",
|
||||
"ferret", "gecko", "ibis", "koala", "lemur", "newt", "panda", "quail",
|
||||
"robin", "seal", "toad", "urchin", "viper", "weasel",
|
||||
]
|
||||
|
||||
public static func generate() -> String {
|
||||
let a = adjectives.randomElement() ?? "amber"
|
||||
let b = textures.randomElement() ?? "quiet"
|
||||
let c = animals.randomElement() ?? "otter"
|
||||
return "\(a)-\(b)-\(c)"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,13 @@ public actor SessionController {
|
||||
private let backend: any AgentBackend
|
||||
private let transcript: TranscriptWriter
|
||||
private let now: @Sendable () -> Date
|
||||
/// Conversational (chat) mode: each turn is its own single-shot run (the first
|
||||
/// via `start`, follow-ups via `resume(prompt:)` — the only reliable way to use
|
||||
/// the approval server, since Claude's streaming-input mode hangs alongside
|
||||
/// `--permission-prompt-tool`). A turn's terminal `runFinished` therefore means
|
||||
/// "awaiting the next message", not "session over". The session ends only on
|
||||
/// explicit `shutdown`/`discard`.
|
||||
private let conversational: Bool
|
||||
|
||||
// Optional git wiring — present for real sessions, nil in pure pipeline tests.
|
||||
private let worktreeManager: (any WorktreeManaging)?
|
||||
@@ -35,6 +42,7 @@ public actor SessionController {
|
||||
project: Project? = nil,
|
||||
worktree: Worktree? = nil,
|
||||
metadataStore: (any SessionMetadataStore)? = nil,
|
||||
conversational: Bool = false,
|
||||
now: @escaping @Sendable () -> Date = { Date() }
|
||||
) {
|
||||
self.session = session
|
||||
@@ -44,6 +52,7 @@ public actor SessionController {
|
||||
self.project = project
|
||||
self.worktree = worktree
|
||||
self.metadataStore = metadataStore
|
||||
self.conversational = conversational
|
||||
self.now = now
|
||||
}
|
||||
|
||||
@@ -136,12 +145,35 @@ public actor SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a follow-up user turn (interactive sessions). The user's text is
|
||||
/// injected into the transcript first so chat history stays complete.
|
||||
/// Submit a user turn. The user's text is injected into the transcript first so
|
||||
/// chat history stays complete. In conversational mode each message is its own
|
||||
/// single-shot run: the first starts the session, later ones `resume` it (which
|
||||
/// also carries the message), so context is retained across turns.
|
||||
public func sendInput(_ input: AgentInput) async throws {
|
||||
if let text = input.plainText, !text.isEmpty {
|
||||
await ingest(synthetic(.userText(makeUserChunk(text))))
|
||||
}
|
||||
|
||||
if conversational {
|
||||
guard runTask == nil else { return } // a turn is already running
|
||||
session.status = .running
|
||||
session.updatedAt = now()
|
||||
let worktreePath = session.worktreePath ?? worktree?.path ?? ""
|
||||
if let backendSessionID = session.backendSessionID {
|
||||
let spec = ResumeSpec(
|
||||
sessionID: session.id, backendSessionID: backendSessionID,
|
||||
worktree: worktreePath, prompt: input)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
} else {
|
||||
// No turn has run yet → this message starts the session.
|
||||
let run = RunSpec(
|
||||
sessionID: session.id, worktree: worktreePath, prompt: input,
|
||||
model: session.model, approvalPolicy: .interactive)
|
||||
consume(backend.start(run), injectingUserText: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try await backend.send(input)
|
||||
if !session.status.isTerminal {
|
||||
session.status = .running
|
||||
@@ -154,6 +186,18 @@ public actor SessionController {
|
||||
return TextChunk(messageID: "user-\(userTurnCount)", text: text, isPartial: false)
|
||||
}
|
||||
|
||||
/// In conversational mode a turn's terminal `runFinished` means "ready for the
|
||||
/// next message", not "session over".
|
||||
private func derivedStatus(_ current: SessionStatus, _ kind: AgentEvent.Kind) -> SessionStatus {
|
||||
if conversational, case .runFinished(let finished) = kind {
|
||||
switch finished.outcome {
|
||||
case .completed, .interrupted, .maxTurns: return .awaitingInput
|
||||
case .errored: return .error
|
||||
}
|
||||
}
|
||||
return SessionStatus.transition(current, on: kind)
|
||||
}
|
||||
|
||||
/// Resolve an outstanding approval. First responder wins inside the backend's
|
||||
/// coordinator; the resulting `approvalResolved` flows back through `ingest`.
|
||||
public func respondToApproval(_ id: ApprovalID, _ decision: Decision) async throws {
|
||||
@@ -167,6 +211,10 @@ public actor SessionController {
|
||||
public func shutdown() async {
|
||||
runTask?.cancel()
|
||||
await backend.shutdown()
|
||||
if !session.status.isTerminal {
|
||||
session.status = .finished
|
||||
session.updatedAt = now()
|
||||
}
|
||||
try? await transcript.sync()
|
||||
try? await transcript.close()
|
||||
finishSubscribers()
|
||||
@@ -230,7 +278,7 @@ public actor SessionController {
|
||||
}
|
||||
|
||||
// 2. Derived status + metadata.
|
||||
session.status = SessionStatus.transition(session.status, on: canonical.kind)
|
||||
session.status = derivedStatus(session.status, canonical.kind)
|
||||
session.lastSeq = canonical.seq
|
||||
session.updatedAt = now()
|
||||
|
||||
@@ -281,9 +329,20 @@ public actor SessionController {
|
||||
|
||||
private func finishRun() async {
|
||||
runTask = nil
|
||||
// The stream ended (process exited). A well-behaved backend already emitted a
|
||||
// terminal runFinished; if not (backend died mid-turn), synthesize one so the
|
||||
// session never lingers in a non-terminal state with the process gone.
|
||||
if conversational {
|
||||
// A turn ended; the session stays alive for the next message. Keep
|
||||
// subscribers attached. Status was set to awaitingInput/error by the
|
||||
// turn's runFinished; if the stream died without one, settle to awaiting.
|
||||
if !session.status.isTerminal && session.status != .awaitingInput {
|
||||
session.status = .awaitingInput
|
||||
session.updatedAt = now()
|
||||
}
|
||||
try? await transcript.sync()
|
||||
return
|
||||
}
|
||||
// One-shot: a well-behaved backend already emitted a terminal runFinished; if
|
||||
// not (backend died mid-turn), synthesize one so the session never lingers
|
||||
// non-terminal with the process gone.
|
||||
if !session.status.isTerminal {
|
||||
await ingest(synthetic(.runFinished(RunFinished(outcome: .interrupted))))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct Spike {
|
||||
var executable = "claude"
|
||||
var includePartialMessages = false
|
||||
var capture = false
|
||||
var keepStdin = false
|
||||
var approvalMode: ApprovalMode = .interactive
|
||||
var allowedTools = ["Read", "Glob", "Grep"]
|
||||
var outputDirectory: String?
|
||||
@@ -53,7 +54,7 @@ struct Spike {
|
||||
executable: options.executable,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
allowedTools: options.allowedTools,
|
||||
closeStdinAfterPrompt: true,
|
||||
closeStdinAfterPrompt: !options.keepStdin,
|
||||
captureDirectory: options.capture ? outputBase.appendingPathComponent("capture") : nil)
|
||||
let backend = ClaudeCodeBackend(configuration: configuration)
|
||||
|
||||
@@ -81,7 +82,9 @@ struct Spike {
|
||||
let stream: AsyncThrowingStream<AgentEvent, Error>
|
||||
if let resume = options.resume {
|
||||
stream = backend.resume(
|
||||
ResumeSpec(sessionID: sessionID, backendSessionID: resume, worktree: options.cwd))
|
||||
ResumeSpec(
|
||||
sessionID: sessionID, backendSessionID: resume, worktree: options.cwd,
|
||||
prompt: options.prompt.map { AgentInput(text: $0) }))
|
||||
} else {
|
||||
stream = backend.start(
|
||||
RunSpec(
|
||||
@@ -223,6 +226,7 @@ struct Spike {
|
||||
case "--claude": options.executable = try value(for: argument)
|
||||
case "--partial": options.includePartialMessages = true
|
||||
case "--capture": options.capture = true
|
||||
case "--keep-stdin": options.keepStdin = true
|
||||
case "--out": options.outputDirectory = try value(for: argument)
|
||||
case "--approvals":
|
||||
let raw = try value(for: argument)
|
||||
|
||||
@@ -49,14 +49,14 @@ struct AppStoreTests {
|
||||
in: store.project(projectID)!, title: "make a file", prompt: "go")
|
||||
store.openSessionID = sessionID
|
||||
|
||||
await waitFor { store.summaries.first?.status == .finished }
|
||||
await waitFor { store.summaries.first?.status == .awaitingInput }
|
||||
// Settle the live diffstat deterministically (the in-stream refresh is
|
||||
// best-effort and can be skipped under heavy parallel git load).
|
||||
await store.refreshOpenStatus()
|
||||
|
||||
let summary = try #require(store.summaries.first)
|
||||
#expect(summary.title == "make a file")
|
||||
#expect(summary.status == .finished)
|
||||
#expect(summary.status == .awaitingInput) // conversational: ready for next message
|
||||
#expect((summary.diffStat?.filesChanged ?? 0) >= 1)
|
||||
#expect(store.summaries(for: projectID).count == 1)
|
||||
|
||||
@@ -79,7 +79,7 @@ struct AppStoreTests {
|
||||
|
||||
let sessionID = try await store.createSession(in: project, title: "ship it", prompt: "go")
|
||||
store.openSessionID = sessionID
|
||||
await waitFor { store.summaries.first?.status == .finished }
|
||||
await waitFor { store.summaries.first?.status == .awaitingInput }
|
||||
|
||||
let result = await store.integrateOpenSession(strategy: .squash)
|
||||
guard case .clean = result else {
|
||||
@@ -97,7 +97,7 @@ struct AppStoreTests {
|
||||
|
||||
let sessionID = try await store.createSession(in: project, title: "scrap", prompt: "go")
|
||||
store.openSessionID = sessionID
|
||||
await waitFor { store.summaries.first?.status == .finished }
|
||||
await waitFor { store.summaries.first?.status == .awaitingInput }
|
||||
let wtPath = (repo.container as NSString).appendingPathComponent(".nucleic-worktrees/repo/scrap")
|
||||
#expect(FileManager.default.fileExists(atPath: wtPath))
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ private func makeController(
|
||||
backend: any AgentBackend,
|
||||
worktreeManager: (any WorktreeManaging)? = nil,
|
||||
project: Project? = nil,
|
||||
worktree: Worktree? = nil
|
||||
worktree: Worktree? = nil,
|
||||
conversational: Bool = false
|
||||
) throws -> (controller: SessionController, transcriptURL: URL, cleanup: @Sendable () -> Void) {
|
||||
let dir = (NSTemporaryDirectory() as NSString)
|
||||
.appendingPathComponent("nucleic-sc-\(UUID().uuidString)")
|
||||
@@ -27,7 +28,7 @@ private func makeController(
|
||||
let controller = SessionController(
|
||||
session: session, backend: backend, transcript: writer,
|
||||
worktreeManager: worktreeManager, project: project, worktree: worktree,
|
||||
now: { fixedNow })
|
||||
conversational: conversational, now: { fixedNow })
|
||||
return (controller, url, { try? FileManager.default.removeItem(atPath: dir) })
|
||||
}
|
||||
|
||||
@@ -183,6 +184,52 @@ struct SessionControllerTests {
|
||||
#expect(assistantTexts == ["First answer.", "Second answer."])
|
||||
}
|
||||
|
||||
@Test func conversationalModeResumesPerTurnAndStaysAlive() async throws {
|
||||
// Each turn is its own single-shot run that emits a terminal runFinished;
|
||||
// in conversational mode that means "awaiting input", and the next message
|
||||
// resumes — modeling per-turn `claude --resume`.
|
||||
let turnCount = LockedBox(0)
|
||||
let backend = ScriptedBackend { e, _ in
|
||||
let n = turnCount.get() + 1
|
||||
turnCount.set(n)
|
||||
e.emit(.sessionStarted(SessionStarted(
|
||||
backendSessionID: "be-conv", model: "m", cwd: "/tmp", toolNames: [])))
|
||||
e.emit(.assistantText(TextChunk(messageID: "a\(n)", text: "answer \(n)", isPartial: false)))
|
||||
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
|
||||
e.emit(.runFinished(RunFinished(outcome: .completed)))
|
||||
}
|
||||
let (controller, _, cleanup) = try makeController(backend: backend, conversational: true)
|
||||
defer { cleanup() }
|
||||
|
||||
let stream = await controller.subscribe()
|
||||
let collected = Task { () -> [AgentEvent] in
|
||||
var events: [AgentEvent] = []
|
||||
for await event in stream { events.append(event) }
|
||||
return events
|
||||
}
|
||||
|
||||
await controller.start(prompt: AgentInput(text: "q1"))
|
||||
await waitForStatus(controller, .awaitingInput)
|
||||
// Terminal runFinished does NOT end a conversational session.
|
||||
#expect(await controller.snapshot.session.status == .awaitingInput)
|
||||
|
||||
try await controller.sendInput(AgentInput(text: "q2"))
|
||||
await waitForStatus(controller, .awaitingInput)
|
||||
|
||||
await controller.shutdown()
|
||||
let events = await collected.value
|
||||
|
||||
let userTexts = events.compactMap { e -> String? in
|
||||
if case .userText(let c) = e.kind { return c.text } else { return nil }
|
||||
}
|
||||
let answers = events.compactMap { e -> String? in
|
||||
if case .assistantText(let c) = e.kind { return c.text } else { return nil }
|
||||
}
|
||||
#expect(userTexts == ["q1", "q2"])
|
||||
#expect(answers == ["answer 1", "answer 2"])
|
||||
#expect(turnCount.get() == 2) // two separate runs (start + resume)
|
||||
}
|
||||
|
||||
@Test func streamErrorSynthesizesTerminalStatus() async throws {
|
||||
struct Boom: Error {}
|
||||
let backend = ScriptedBackend { e, _ in
|
||||
|
||||
Reference in New Issue
Block a user