Skip to content

feat(dsh): integrate DeepSeek Harness through ACP - #1632

Merged
tiann merged 6 commits into
tiann:mainfrom
swear01:feat/dsh-acp
Aug 22, 2026
Merged

feat(dsh): integrate DeepSeek Harness through ACP#1632
tiann merged 6 commits into
tiann:mainfrom
swear01:feat/dsh-acp

Conversation

@swear01

@swear01 swear01 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add DeepSeek Harness as a remote-only HAPI agent flavor
  • reuse the existing ACP stdio backend and standard HAPI chat/permission surfaces
  • launch the official/community ACP server through configurable command + JSON args
  • keep DSH's fresh-session-only, server-owned model/policy contract honest
  • reject unsupported resume/model controls and non-remote machine spawns

ACP setup

Default command: dsh-acp-demo

export HAPI_DSH_ACP_COMMAND=pnpm
export HAPI_DSH_ACP_ARGS_JSON='["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]'

The official @deepseek-ai/dsh-acp-demo@0.1.0-rc.7 composition requires its own Cordis config and DEEPSEEK_API_KEY. HAPI passes an empty MCP list because the official server rejects injected MCP servers.

Known ACP limitations

  • remote-only
  • fresh sessions only; no native resume/fork
  • model/effort and overall permission policy remain DSH-composition-owned
  • committed assistant text and one-shot permission requests only; no live DSH tool/reasoning telemetry

Verification

  • bun typecheck
  • bun run test
  • targeted DSH/runner/shared/hub/web tests

Closes #1538

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Blocker] Add DSH to the native flavor catalogs before publishing the generated catalog — shared/fixtures/catalogs/modes.json:128 now contains dsh, but iOS still treats it as an unknown flavor and falls back to Claude permission modes. The current package-tests check fails at ios/Packages/HapiKit/Tests/HapiProtocolTests/CatalogTests.swift:48 and :55; Android likewise renders DSH as Other with Claude permission controls.
    Suggested fix:

    case dsh
    // Add .dsh to knownFlavors/raw-value/label switches.
    case .dsh, .pi: return [] // permissionModes
    data object Dsh : AgentFlavor { override val id = "dsh" }
    // Add Dsh to KNOWN/LABELS; do not grant model-change capability.
    "dsh" -> emptyList() // PermissionModes.forFlavor
  • [Major] Managed DSH launches still inherit the hidden global YOLO preference — the managed descriptor at shared/src/agentConfig.ts:69 hides the toggle, but web/src/components/NewSession/index.tsx:1486 still sends yoloMode; the runner appends --yolo, and cli/src/commands/dsh.ts:10 rejects it. Any user who previously enabled YOLO for another flavor cannot start DSH.
    Suggested fix:

    yolo: agent === 'dsh' || agent === 'grok' || usesCodexFamilyPermissions
        ? undefined
        : yoloMode,
  • [Major] A single prompt failure permanently ends a fresh-only DSH conversation — cli/src/dsh/dshRemoteLauncher.ts:86 rethrows after reporting a turn error, so runDsh marks the process crashed and disconnects. Because DSH sessions with messages cannot resume, a transient model/API error prevents any retry in the same context.
    Suggested fix:

    } catch (error) {
        const message = error instanceof Error ? error.message : String(error)
        logger.warn('[dsh-acp] prompt failed', { message })
        this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
        // Keep the ACP session alive so the next queued prompt can retry.
    }

Summary

Review mode: initial

Three findings: one blocking native protocol/catalog mismatch and two DSH runtime regressions.

Testing

  • Not run locally (automation; PR content treated as untrusted). Current-head checks observed: main test/integration pass; iOS package-tests fails on the added dsh catalog entry.

HAPI Bot

"tone": "danger"
}
],
"dsh": [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Keep the native flavor catalogs in sync with this generated key.

iOS CatalogTests compares these keys exactly with AgentFlavor.knownFlavors; dsh is absent there, so the current package-tests job already fails and permissionModes(forFlavor: "dsh") incorrectly falls back to Claude modes instead of []. Android also maps dsh to Other, showing an unknown label and Claude permission controls.

Suggested fix:

case dsh
// Add .dsh to knownFlavors, raw-value parsing, and displayLabel.
case .dsh, .pi: return [] // permissionModes
data object Dsh : AgentFlavor { override val id = "dsh" }
// Add Dsh to KNOWN/LABELS; omit model-change capability.
"dsh" -> emptyList() // PermissionModes.forFlavor

Comment thread shared/src/agentConfig.ts
{ id: 'serviceTier', section: 'settings', kind: 'select', optionSource: 'model', availability: 'both' },
{ id: 'collaborationMode', section: 'settings', kind: 'select', optionSource: 'static', availability: 'both' }
),
dsh: fields(MANAGED_PERMISSION),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] The managed DSH permission state still submits the hidden global YOLO preference.

NewSession keeps yoloMode across flavors and line 1486 sends it for every non-Grok/non-Codex-family agent. The runner then appends --yolo, while parseDshCommandOptions rejects that flag. A user who enabled YOLO for Claude/Pi and switches to DSH gets a failed child process even though this UI says permissions are server-managed.

Suggested fix:

yolo: agent === 'dsh' || agent === 'grok' || usesCodexFamilyPermissions
    ? undefined
    : yoloMode,

Add a form test that starts with persisted YOLO enabled, selects DSH, and asserts spawnSession receives no yolo field.

Comment thread cli/src/dsh/dshRemoteLauncher.ts Outdated
logger.warn('[dsh-acp] prompt failed', { message })
this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
this.messageBuffer.addMessage(`DSH prompt failed: ${message}`, 'status')
throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Do not crash a fresh-only session for a single prompt error.

This rethrow exits the loop, runDsh marks the session crashed, and cleanup disconnects the ACP process. Since DSH deliberately stores no resumable agent id, any transient API/model error leaves a conversation with user messages permanently non-resumable. The other ACP launchers report the turn error and keep accepting prompts.

Suggested fix:

} catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    logger.warn('[dsh-acp] prompt failed', { message })
    this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
    // Keep the ACP session alive for the next queued prompt.
}

Cover this with a backend mock that rejects the first prompt and successfully processes the second.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] DSH spawn construction still emits permission flags that its parser rejects — the new runner test expects --permission-mode read-only, but parseDshCommandOptions allows zero permission modes; persisted YOLO from Web/iOS/Android similarly becomes the explicitly rejected --yolo. Evidence: cli/src/runner/buildCliArgs.test.ts:370, cli/src/commands/dsh.ts:9.
    Suggested fix:

    if (agent !== 'pi' && agent !== 'dsh') {
        // append --permission-mode / --yolo
    }

    Also render DSH as managed and omit yolo in all three create forms.

  • [Major] A single prompt failure still destroys a fresh-only DSH conversation — rethrowing exits the launcher, marks the process crashed, and disconnects the ACP server; DSH stores no resumable agent id. Evidence: cli/src/dsh/dshRemoteLauncher.ts:86.
    Suggested fix:

    } catch (error) {
        const message = error instanceof Error ? error.message : String(error)
        logger.warn('[dsh-acp] prompt failed', { message })
        this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
        // Keep the ACP session alive for the next prompt.
    }

Summary

Review mode: follow-up after new commits

The native catalog mismatch from the prior review is addressed. Two Major runtime regressions remain: invalid managed-permission flags can prevent DSH startup, and turn errors terminate a non-resumable conversation.

Testing

  • Not run locally (automation; PR content treated as untrusted).
  • Current-head checks observed: Android build, fixture drift gate, iOS package/app, and integration pass; main test still in progress.
  • Add coverage for persisted YOLO across Web/iOS/Android, DSH runner arguments with permission inputs, and a rejected first prompt followed by a successful second prompt.

HAPI Bot

Comment thread cli/src/runner/buildCliArgs.test.ts Outdated
'--hapi-starting-mode', 'remote',
'--started-by', 'runner',
'--existing-session-id', 'existing-hub-id',
'--permission-mode', 'read-only'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This expectation builds a DSH command that the DSH parser rejects.

parseDshCommandOptions() calls parseRemoteAgentCommandOptions(commandArgs, [], ['remote']), so --permission-mode read-only exits before ACP initialization. The same generic runner branch appends --yolo when a persisted Web/iOS/Android toggle is true, and DSH explicitly rejects that flag too. The newly added mobile catalog entries make DSH selectable, while both native forms still route non-Pi agents through the YOLO control.

Suggested fix:

if (agent !== 'pi' && agent !== 'dsh') {
    if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) {
        args.push('--permission-mode', options.permissionMode)
    } else if (yolo) {
        args.push('--yolo')
    }
}

Then assert DSH args contain neither flag, change Web to omit yolo for dsh, and render DSH as .managed / PermissionUi.Managed in the native forms.

Comment thread cli/src/dsh/dshRemoteLauncher.ts Outdated
logger.warn('[dsh-acp] prompt failed', { message })
this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
this.messageBuffer.addMessage(`DSH prompt failed: ${message}`, 'status')
throw error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Do not tear down a fresh-only session for one failed or cancelled turn.

This rethrow exits runMainLoop(), runDsh() marks the process crashed, and cleanup disconnects the ACP server. Because DSH deliberately persists no resumable agent id, a transient API error or cancelPrompt() rejection permanently ends the conversation. The existing Kimi ACP launcher reports the turn error and continues accepting prompts.

Suggested fix:

} catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    logger.warn('[dsh-acp] prompt failed', { message })
    this.session.sendSessionEvent({ type: 'message', message: `DSH prompt failed: ${message}` })
    this.messageBuffer.addMessage(`DSH prompt failed: ${message}`, 'status')
    // No rethrow: allow the next queued prompt to retry.
}

Add a launcher test whose first prompt() rejects and whose second prompt succeeds.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Emit DSH ready after each completed turn, not at ACP startup — ready is the completion signal used for “Ready for input” notifications. The current event fires before any prompt, while the prompt finally block only clears thinking; successful, failed, and cancelled turns therefore produce no completion notification. Evidence: cli/src/dsh/dshRemoteLauncher.ts:67, related handling hub/src/notifications/notificationHub.ts:70.
    Suggested fix:

    // Remove the startup ready event.
    } finally {
        this.session.onThinkingChange(false)
        await this.permissionHandler?.cancelAll('Prompt finished')
        if (this.session.queue.size() === 0 && !this.shouldExit) {
            this.session.sendSessionEvent({ type: 'ready' })
        }
    }
  • [Major] Native DSH create forms still expose an ignored YOLO control — adding DSH as a no-mode flavor makes both native UIs fall through to YoloToggle because only Pi is rendered as managed. Their spawn builders also serialize yolo; the runner now discards it, so the permission control appears security-relevant but cannot change DSH policy. Evidence: ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift:137; related contexts ios/Hapi/Features/NewSession/NewSessionModel.swift:281, ios/Packages/HapiKit/Sources/HapiClient/NewSession/NewSessionForm.swift:177, android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionViewModel.kt:605, android/app/src/main/kotlin/app/hapi/companion/feature/newsession/NewSessionForm.kt:98.
    Suggested fix:

    if agent == .pi || agent == .dsh { return .managed }
    // ...
    yolo: (agent == .dsh || isGrok || codexFamily) ? nil : form.yolo
    agent == "pi" || agent == "dsh" -> PermissionUi.Managed
    // ...
    yolo = if (agent == "dsh" || isGrok || codexFamily) null else form.yolo

Summary

Review mode: follow-up after new commits

The follow-up removes unsupported runner flags and keeps the ACP process alive after prompt failures. Two Major issues remain: turn completion signaling is misplaced, and native permission UI still advertises a DSH policy control that has no effect.

Testing

  • Not run locally (automation; PR content treated as untrusted).
  • Current-head CI observed passing: test, integration, build, Android app-build, iOS package-tests, and fixture drift-gate.
  • Add tests asserting no startup ready, one ready after each completed/failed prompt, and managed/no-yolo DSH creation on iOS and Android.

HAPI Bot

Comment thread cli/src/dsh/dshRemoteLauncher.ts Outdated
onAbort: () => this.handleAbort(),
onSwitch: () => this.handleExitFromUi()
})
this.session.sendSessionEvent({ type: 'ready' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Emit ready after each prompt, not during startup.

This event is consumed as the “agent finishes working” signal and triggers Ready-for-input notifications. Here it fires immediately after ACP initialization, before any work. The prompt finally block never sends it, so every actual DSH turn completes without the remote completion notification.

Suggested fix:

// Remove this startup event.
} finally {
    this.session.onThinkingChange(false)
    await this.permissionHandler?.cancelAll('Prompt finished')
    if (this.session.queue.size() === 0 && !this.shouldExit) {
        this.session.sendSessionEvent({ type: 'ready' })
    }
}

Add a test that verifies ready is absent before prompt() settles and emitted after both a successful and a rejected turn.

case .agy: return .alwaysProceed
case .codex, .copilot, .cursor, .gemini, .kimi, .opencode: return .yolo
case .pi, .other: return nil
case .dsh, .pi, .other: return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] DSH still falls through to the native YOLO toggle even though this mapping says no HAPI policy applies.

Both native UI selectors special-case only Pi as Managed, so DSH renders YoloToggle(...nil). Both spawn builders also include yolo for DSH. The runner now silently drops that field, leaving users with a permission/security control that cannot affect the ACP composition.

Suggested fix:

if agent == .pi || agent == .dsh { return .managed }
// buildSpawnRequest:
yolo: (agent == .dsh || isGrok || codexFamily) ? nil : form.yolo
agent == "pi" || agent == "dsh" -> PermissionUi.Managed
// buildSpawnRequest:
yolo = if (agent == "dsh" || isGrok || codexFamily) null else form.yolo

Add exact spawn-body and permission-UI tests for DSH on both platforms.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No reportable issues found in the latest full diff. The prior Major findings are resolved at this head. Residual risk: cli/src/dsh/dshRemoteLauncher.test.ts covers successful prompt completion, but does not directly exercise rejected prompts, abort/cancellation, or permission cleanup.

Testing

  • Not run (automation; PR content treated as untrusted).
  • Current-head CI observed passing: integration, build, Android app-build, iOS package-tests, and fixture drift-gate. The main test job was still pending at review time.
  • Suggested coverage: rejected backend.prompt(), abort during an active prompt, and pending ACP permission cancellation.

HAPI Bot

@tiann
tiann merged commit be1ef2a into tiann:main Aug 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DeepSeek Harness support

2 participants