diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index ea45999385d..33906452026 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -30,13 +30,13 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendN } return ( -
{entryPreview(entry, c)}
{(attachmentsCount > 0 || isEditing) && (-- +- + ) } const MarkdownTextImpl = () => { return ( -+ ++ - +- + ) } diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 21c91bf8b3d..315bee5c12b 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -236,6 +236,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> > {hoistedTodos.length > 0 &&+ ++ } + {messageStatus === 'running' && } {previewTargets.length > 0 && ( {previewTargets.map(target => ( @@ -287,6 +288,39 @@ const ResponseLoadingIndicator: FC = () => { ) } +// Seconds of no visible output (text or part count) before a still-running turn +// is treated as stalled and the thinking indicator returns at the tail. +const STREAM_STALL_S = 2 + +// Tail "still thinking" indicator: the pre-first-token spinner goes away once +// text flows, but if the stream then goes quiet mid-turn (tool think-time, +// provider stall) nothing signals that work continues. Watch a per-render +// activity signal; when it hasn't changed for STREAM_STALL_S, re-show the +// dither + a timer counting from the last activity. +const StreamStallIndicator: FC<{ activity: string }> = ({ activity }) => { + const [stalled, setStalled] = useState(false) + + useEffect(() => { + setStalled(false) + const id = window.setTimeout(() => setStalled(true), STREAM_STALL_S * 1000) + + return () => window.clearTimeout(id) + }, [activity]) + + const elapsed = useElapsedSeconds(stalled) + + if (!stalled) { + return null + } + + return ( +)} - {view.inlineDiff &&+ + + ) +} + const ImageGenerateTool: FC+ = ({ result }) => { const generatedImage = useGeneratedImageContext() const running = result === undefined @@ -434,6 +468,22 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) + // A reasoning group with no actual text is pure noise — drop the whole + // "Thinking" disclosure rather than leave an empty header eating a row. This + // applies live too: encrypted/spinner-coerced reasoning (Opus reasoning max) + // never carries visible text, and the bottom-of-thread loader already signals + // "thinking", so an empty header is never wanted. Real reasoning surfaces the + // instant its first token lands. + const hasContent = useAuiState(s => + s.message.parts + .slice(Math.max(0, startIndex), endIndex + 1) + .some(p => p?.type === 'reasoning' && typeof p.text === 'string' && p.text.trim().length > 0) + ) + + if (!hasContent) { + return null + } + return ( {children} @@ -449,7 +499,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te return ( } diff --git a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx index b3dfff2e928..0f897e54d75 100644 --- a/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-approval-group.test.tsx @@ -1,5 +1,5 @@ import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearAllPrompts, setApprovalRequest } from '@/store/prompts' @@ -8,12 +8,11 @@ import { $toolDisclosureStates } from '@/store/tool-view' import { Thread } from './thread' -// Regression coverage for the "approval buried behind a collapsed tool group" -// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the -// pending tool's inline ApprovalBar lives inside the group body — which is -// `hidden` until expanded. A live approval must surface WITHOUT the user -// expanding anything, so ToolGroupSlot force-opens its body while an approval -// targeting one of its pending tools is in flight. +// Regression coverage for the "approval must never be buried" bug. Tools now +// render as a flat list (no collapsible "N steps" group), so a pending tool's +// inline ApprovalBar is always in the visual flow — never inside a `hidden` +// body. These assert the bar shows only when an approval is live and is never +// trapped under a `hidden` ancestor. const createdAt = new Date('2026-06-03T00:00:00.000Z') @@ -71,8 +70,7 @@ stubOffsetDimension('offsetWidth', 'clientWidth', 800) stubOffsetDimension('offsetHeight', 'clientHeight', 600) // A running assistant message with two tools: a completed read_file plus a -// pending terminal (no result). Two visible tools → ToolGroupSlot groups them -// behind a collapsed "Tool actions · 2 steps" header. +// pending terminal (no result), rendered as a flat two-row list. function groupedPendingMessage(): ThreadMessage { return { id: 'assistant-group-1', @@ -132,32 +130,28 @@ afterEach(() => { $activeSessionId.set(null) }) -describe('ToolGroupSlot approval surfacing', () => { - it('hides the grouped pending tool body when there is no approval', async () => { +describe('flat tool list approval surfacing', () => { + it('renders no inline approval bar when there is no live approval', async () => { const { container } = render( ) - // Group header renders collapsed; the inline approval strip lives in the - // hidden body, so with no live approval it must not render at all (the - // ApprovalBar returns null when $approvalRequest is empty). + // The pending terminal row mounts immediately, but its inline ApprovalBar + // returns null while $approvalRequest is empty. await waitFor(() => { - expect(screen.getByText(/Tool actions/)).toBeTruthy() + expect(container.querySelectorAll('[data-slot="tool-block"]').length).toBeGreaterThan(0) }) expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull() }) - it('force-opens the group body so the approval surfaces without expanding', async () => { + it('surfaces the approval inline and never under a hidden ancestor', async () => { setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' }) const { container } = render( ) - // Even though the group defaults collapsed, the live approval forces the - // body open so the inline controls are visible (and reachable, not in a - // hidden subtree) immediately. await waitFor(() => { const bar = container.querySelector('[data-slot="tool-approval-inline"]') expect(bar).not.toBeNull() - // The forced-open group body must not be hidden — assert no ancestor - // carries the `hidden` attribute that would keep the bar off-screen. + // Flat rows live directly in the flow — nothing should ever wrap the bar + // in a `hidden` subtree. expect(bar?.closest('[hidden]')).toBeNull() }) }) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 25fa75190a1..f827384682e 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -88,10 +88,12 @@ const TOOL_META: Record = { tone: 'browser' }, browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' }, + clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' }, edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }, execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, + patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' }, read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, session_search_recall: { @@ -102,6 +104,7 @@ const TOOL_META: Record = { }, terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' }, todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' }, + vision_analyze: { done: 'Analyzed image', pending: 'Analyzing image', icon: 'eye', tone: 'image' }, web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' }, web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' }, write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' } @@ -1268,124 +1271,3 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { tone: meta.tone } } - -function isToolPart(part: unknown): part is ToolPart { - if (!part || typeof part !== 'object') { - return false - } - - const row = part as Record - - return row.type === 'tool-call' && typeof row.toolName === 'string' -} - -export function groupToolParts(content: unknown): ToolPart[][] { - if (!Array.isArray(content)) { - return [] - } - - const groups: ToolPart[][] = [] - let current: ToolPart[] = [] - - for (const part of content) { - // todo parts render in their own hoisted panel; skip from grouped tools. - if (isToolPart(part) && part.toolName !== 'todo') { - current.push(part) - - continue - } - - if (current.length) { - groups.push(current) - current = [] - } - } - - if (current.length) { - groups.push(current) - } - - return groups -} - -export function groupStatus(parts: ToolPart[]): ToolStatus { - if (parts.some(p => p.result === undefined)) { - return 'running' - } - - const statuses = parts.map(part => toolStatus(part, parseMaybeObject(part.result))) - const hasError = statuses.includes('error') - - if (!hasError) { - return 'success' - } - - return statuses.at(-1) === 'success' ? 'warning' : 'error' -} - -export function groupTitle(parts: ToolPart[]): string { - const prefix = PREFIX_META.find(p => parts.every(part => part.toolName.startsWith(p.prefix))) - const verb = prefix?.verb || 'Tool' - - return `${verb} actions · ${parts.length} steps` -} - -export function groupPreviewTargets(parts: ToolPart[]): string[] { - const seen = new Set () - const targets: string[] = [] - - for (const part of parts) { - const view = buildToolView(part, inlineDiffFromResult(part.result)) - const target = view.previewTarget - - if (target && isPreviewableTarget(target) && !seen.has(target)) { - seen.add(target) - targets.push(target) - } - } - - return targets -} - -export function groupFailedStepCount(parts: ToolPart[]): number { - return parts.filter(part => toolStatus(part, parseMaybeObject(part.result)) === 'error').length -} - -export function groupTotalDurationLabel(parts: ToolPart[]): string { - const seconds = parts.reduce((sum, part) => { - const value = numberValue(parseMaybeObject(part.result).duration_s) - - return sum + (value && value > 0 ? value : 0) - }, 0) - - if (!seconds) { - return '' - } - - return formatDurationSeconds(seconds) -} - -export function groupTailSubtitle(parts: ToolPart[]): string { - const tail = parts.at(-1) - - return tail ? buildToolView(tail, '').subtitle : '' -} - -export function groupCopyText(parts: ToolPart[]): string { - return parts - .map(part => { - const view = buildToolView(part, '') - const lines = [view.title] - - if (view.subtitle && view.subtitle !== view.title) { - lines.push(view.subtitle) - } - - if (view.detail && view.detail !== view.subtitle) { - lines.push(view.detail) - } - - return lines.join('\n') - }) - .join('\n\n') -} diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index ff0a4652fc0..3afd202e12f 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -3,7 +3,6 @@ import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react' -import { useShallow } from 'zustand/shallow' import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' @@ -21,20 +20,13 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f import { AlertCircle, CheckCircle2 } from '@/lib/icons' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' -import { $approvalRequest } from '@/store/prompts' import { $toolInlineDiffs } from '@/store/tool-diffs' import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view' -import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval' +import { PendingToolApproval } from './tool-approval' import { - groupCopyText as buildGroupCopyText, buildToolView, cleanVisibleText, - groupFailedStepCount, - groupPreviewTargets, - groupStatus, - groupTitle, - groupTotalDurationLabel, inlineDiffFromResult, isPreviewableTarget, looksRedundant, @@ -47,14 +39,10 @@ import { type ToolStatus } from './tool-fallback-model' -// Tool names that ChainToolFallback intercepts and renders as something -// other than a ToolEntry — they don't count toward "is this a group of -// tool calls?" because they have no visible tool block. -const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify']) - -// `true` when the current ToolEntry is being rendered inside a group -// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that -// the group already shows. +// `true` when a ToolEntry is rendered inside an embedding wrapper that owns +// the per-row chrome (timer / preview). The flat ToolGroupSlot sets this +// false, so every row currently owns its own chrome; kept as a seam for any +// future embedding surface. const ToolEmbedContext = createContext(false) // Shared header chrome for tool rows. Both the single-tool DisclosureRow @@ -263,6 +251,7 @@ function ToolEntry({ part }: ToolEntryProps) { const hasExpandableContent = Boolean( (view.previewTarget && isPreviewableTarget(view.previewTarget)) || view.imageUrl || + view.inlineDiff || showDetail || hasSearchHits || toolViewMode === 'technical' @@ -403,153 +392,42 @@ function ToolEntry({ part }: ToolEntryProps) { )} } + {open && view.inlineDiff && }
`New session in ${label}`,
reorderWorkspace: label => `Reorder workspace ${label}`,
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 5d0d4ca2538..21de81a8c1a 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -278,11 +278,21 @@
--composer-shell-pad-block-end: 0.625rem;
--message-text-indent: 0.75rem;
--conversation-text-font-size: 0.8125rem;
- --conversation-tool-font-size: var(--conversation-text-font-size);
+ --conversation-tool-font-size: 0.6875rem;
--conversation-caption-font-size: 0.75rem;
--conversation-line-height: 1.125rem;
--conversation-caption-line-height: 1rem;
--conversation-turn-gap: 0.375rem;
+ /* Gap between top-level turn blocks (prose ↔ tools ↔ thinking) — enough air
+ that scaffolding reads as separate from the reply, not crammed into it. */
+ --turn-block-gap: 0.75rem;
+ /* Tight gap between tool rows inside a single action group, so a back-to-back
+ run still reads as one cohesive sequence. */
+ --tool-row-gap: 0.375rem;
+ /* Paragraph spacing — vertical gap between prose paragraphs, both inside a
+ markdown block and between consecutive prose parts. Single knob; tweak
+ freely. */
+ --paragraph-gap: 0.45rem;
--sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem;
@@ -798,14 +808,27 @@ canvas {
font-size: inherit;
}
-/* Streamed prose hangs slightly indented from the tool/todo column so the
- reading column reads as a "reply" within the conversation gutter. Tools,
- todos, and thinking blocks keep the existing --message-text-indent so they
- remain flush with the user message text above them. */
-[data-slot='aui_assistant-message-content'] > .aui-md {
- padding-inline-start: var(--md-text-indent, 0.5rem);
+/* Tailwind Typography sets `.prose :where(p) { margin: 1.25em }` (~16px). That
+ selector ties our `my-*` utility on specificity and wins on source order, so
+ paragraph spacing must be reclaimed here at higher specificity. One tight
+ top-margin (bottom zeroed to avoid doubling), first child reset to flush. */
+[data-slot='aui_assistant-message-content'] .aui-md :where(p) {
+ margin-block: var(--paragraph-gap) 0;
}
+/* First rendered element of a prose block is flush — the block-level gap above
+ (tool / paragraph) already provides the separation. Reach one level deep too:
+ Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the
+ first child's first child. */
+[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
+[data-slot='aui_assistant-message-content'] .aui-md > :first-child > :first-child {
+ margin-top: 0;
+}
+
+/* Prose, tools, todos, and thinking all share one left edge (the message
+ content's --message-text-indent). No extra prose indent — a single gutter
+ reads cleaner than a ragged tool-vs-reply column. */
+
[data-slot='aui_user-message-root'] {
top: var(--sticky-human-top);
}
@@ -816,12 +839,13 @@ canvas {
}
/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long
- prompt doesn't dominate the viewport while you read the response stuck
- beneath it. The clamp lifts on hover / focus (clicking the bubble opens the
- edit composer, which already shows the full text). --human-msg-full is the
- measured content height (set in UserMessage) so expand/collapse animates to
- the real height instead of overshooting the cap. */
+ prompt doesn't dominate the viewport. The clamp lifts on focus only (clicking
+ opens the edit composer, which shows the full text) — not on hover, so the
+ bubble doesn't jump as the pointer passes over it. --human-msg-full is the
+ measured content height (set in UserMessage) so it animates to the real
+ height instead of overshooting the cap. */
.sticky-human-clamp {
+ cursor: pointer;
max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem);
overflow: hidden;
transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1);
@@ -832,7 +856,6 @@ canvas {
mask-image: linear-gradient(to bottom, #000 55%, transparent);
}
-.composer-human-message:hover .sticky-human-clamp,
.composer-human-message:focus-within .sticky-human-clamp {
max-height: min(var(--human-msg-full, 24rem), 24rem);
overflow-y: auto;
@@ -992,7 +1015,7 @@ canvas {
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
contain: none;
overflow: visible;
- margin-block: 0.375rem !important;
+ margin-block: var(--paragraph-gap) 0 !important;
padding: 0 !important;
gap: 0 !important;
border: 0 !important;
@@ -1006,6 +1029,11 @@ canvas {
}
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='code-card'] {
+ /* Streamdown nests blocks, so the container's child-combinator rhythm can't
+ reach the card. Carry the paragraph gap on the card itself (top-owned);
+ collapses cleanly with the wrapper's margin when one is present, and the
+ first-child reset still flushes a leading code block. */
+ margin-block: var(--paragraph-gap) 0;
position: relative;
transition:
border-color 180ms ease-out,
@@ -1075,34 +1103,25 @@ canvas {
opacity: 1;
}
-/* Conversation block rhythm. Consecutive tool calls stay tight so a step
- sequence reads as one action group; the gap between any scaffolding
- block and adjacent prose bumps up so the model's reply visually
- separates from its scaffolding. */
-[data-slot='tool-block'] + [data-slot='tool-block'] {
- margin-top: 0.375rem;
-}
-
-[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
- margin-top: 0.625rem;
-}
-
+/* Conversation block rhythm. assistant-ui renders each range as a direct child
+ of the message content with no per-part wrapper, so adjacency rules cover
+ every pairing — first block needs no reset, nested tool rows are untouched.
+ Two tiers: scaffolding (tool / thinking) gets a roomy block gap so it reads
+ as separate from the reply; consecutive prose collapses to a tight paragraph
+ rhythm so split-out text parts don't look like a big gap. */
+/* Scaffolding adjacent to anything → roomy block gap. */
[data-slot='aui_assistant-message-content']
- :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'])
- + .aui-md,
+ > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'])
+ + :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], .aui-md),
[data-slot='aui_assistant-message-content']
- .aui-md
+ > .aui-md
+ :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) {
- margin-top: 1rem;
+ margin-top: var(--turn-block-gap);
}
-[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'],
-[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] {
- margin-top: 0.75rem;
-}
-
-[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
- margin-top: 0;
+/* Prose ↔ prose → tight paragraph rhythm, matching in-block paragraph spacing. */
+[data-slot='aui_assistant-message-content'] > .aui-md + .aui-md {
+ margin-top: var(--paragraph-gap);
}
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
diff --git a/tui_gateway/ws.py b/tui_gateway/ws.py
index e822f7f874c..1babfc1d3c2 100644
--- a/tui_gateway/ws.py
+++ b/tui_gateway/ws.py
@@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio
import json
import logging
+import socket
from typing import Any
from tui_gateway import server
@@ -137,6 +138,24 @@ def _ws_peer_label(ws: Any) -> str:
return f"{host}:{port}" if port is not None else host
+def _disable_nagle(ws: Any) -> None:
+ """Disable Nagle so streamed JSON-RPC frames go out individually.
+
+ Without it the kernel coalesces the small per-token frames, so a burst after
+ the model's think-pause lands on the client in one tick and no client-side
+ smoothing can recover the cadence. GUI/WS only; chat platforms don't hit
+ this path. Best-effort — skip silently if the socket isn't reachable.
+ """
+ try:
+ scope = getattr(ws, "scope", None) or {}
+ transport = (scope.get("extensions") or {}).get("transport") or getattr(ws, "transport", None)
+ sock = transport.get_extra_info("socket") if transport is not None else None
+ if sock is not None:
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ except Exception as exc: # pragma: no cover - best-effort tuning
+ _log.debug("ws TCP_NODELAY skip: %s", exc)
+
+
async def handle_ws(ws: Any) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``."""
peer = _ws_peer_label(ws)
@@ -150,6 +169,9 @@ async def handle_ws(ws: Any) -> None:
try:
await ws.accept()
disconnect_reason = "connected"
+ # Push small streamed frames out immediately instead of letting Nagle
+ # batch them — keeps the live token cadence intact for GUI clients.
+ _disable_nagle(ws)
_log.info("ws accepted peer=%s", peer)
transport = WSTransport(ws, asyncio.get_running_loop(), peer=peer)