jupyter-ai-acp-client 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jupyter_ai_acp_client/__init__.py +25 -0
- jupyter_ai_acp_client/_version.py +4 -0
- jupyter_ai_acp_client/acp_personas/claude.py +30 -0
- jupyter_ai_acp_client/acp_personas/test.py +26 -0
- jupyter_ai_acp_client/base_acp_persona.py +163 -0
- jupyter_ai_acp_client/default_acp_client.py +368 -0
- jupyter_ai_acp_client/extension_app.py +29 -0
- jupyter_ai_acp_client/routes.py +79 -0
- jupyter_ai_acp_client/static/claude.svg +7 -0
- jupyter_ai_acp_client/static/test.svg +40 -0
- jupyter_ai_acp_client/terminal_manager.py +334 -0
- jupyter_ai_acp_client/tests/__init__.py +1 -0
- jupyter_ai_acp_client/tests/test_routes.py +13 -0
- jupyter_ai_acp_client-0.0.1.data/data/etc/jupyter/jupyter_server_config.d/jupyter_ai_acp_client.json +7 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/install.json +5 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/package.json +222 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/728.f69b40505cc5a8669c1e.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/750.a29148656dc2b5c07d11.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/remoteEntry.e615ae2e4254ce11d925.js +1 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/style.js +4 -0
- jupyter_ai_acp_client-0.0.1.data/data/share/jupyter/labextensions/@jupyter-ai/acp-client/static/third-party-licenses.json +16 -0
- jupyter_ai_acp_client-0.0.1.dist-info/METADATA +304 -0
- jupyter_ai_acp_client-0.0.1.dist-info/RECORD +26 -0
- jupyter_ai_acp_client-0.0.1.dist-info/WHEEL +4 -0
- jupyter_ai_acp_client-0.0.1.dist-info/entry_points.txt +3 -0
- jupyter_ai_acp_client-0.0.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from jupyter_server.base.handlers import APIHandler
|
|
6
|
+
import tornado
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from .base_acp_persona import BaseAcpPersona
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from jupyter_server_fileid.manager import BaseFileIdManager
|
|
12
|
+
from jupyter_ai_persona_manager import PersonaManager
|
|
13
|
+
|
|
14
|
+
class AcpSlashCommand(BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
|
|
18
|
+
class AcpSlashCommandsResponse(BaseModel):
|
|
19
|
+
commands: list[AcpSlashCommand] = []
|
|
20
|
+
|
|
21
|
+
class AcpSlashCommandsHandler(APIHandler):
|
|
22
|
+
@property
|
|
23
|
+
def file_id_manager(self) -> BaseFileIdManager:
|
|
24
|
+
manager = self.serverapp.web_app.settings["file_id_manager"]
|
|
25
|
+
assert manager
|
|
26
|
+
return manager
|
|
27
|
+
|
|
28
|
+
@tornado.web.authenticated
|
|
29
|
+
def get(self, persona_mention_name: str = ""):
|
|
30
|
+
# get chat path
|
|
31
|
+
chat_path = self.get_argument('chat_path', None)
|
|
32
|
+
if not chat_path:
|
|
33
|
+
# raise HTTP error: chat_path is required URL query arg
|
|
34
|
+
raise tornado.web.HTTPError(400, "chat_path is required as a URL query parameter")
|
|
35
|
+
|
|
36
|
+
# get chat room ID using file ID manager
|
|
37
|
+
file_id = self.file_id_manager.get_id(chat_path)
|
|
38
|
+
if not file_id:
|
|
39
|
+
raise tornado.web.HTTPError(404, f"Chat not found: {chat_path}")
|
|
40
|
+
room_id = f"text:chat:{file_id}"
|
|
41
|
+
|
|
42
|
+
# get persona manager
|
|
43
|
+
persona_manager: PersonaManager | None = self.serverapp.web_app.settings.get("jupyter-ai", {}).get("persona-managers", {}).get(room_id, None)
|
|
44
|
+
if not persona_manager:
|
|
45
|
+
raise tornado.web.HTTPError(404, f"Chat not initialized: {chat_path}")
|
|
46
|
+
|
|
47
|
+
if persona_mention_name:
|
|
48
|
+
for p in persona_manager.personas.values():
|
|
49
|
+
if p.as_user().mention_name == persona_mention_name:
|
|
50
|
+
persona = p
|
|
51
|
+
break
|
|
52
|
+
if not persona:
|
|
53
|
+
# raise HTTP error: persona not found
|
|
54
|
+
raise tornado.web.HTTPError(404, f"Persona not found: @{persona_mention_name}")
|
|
55
|
+
else:
|
|
56
|
+
persona = persona_manager.last_mentioned_persona or persona_manager.default_persona
|
|
57
|
+
|
|
58
|
+
# Return early with empty response if either:
|
|
59
|
+
# 1. no default persona, or
|
|
60
|
+
# 2. default persona is not ACP persona, or
|
|
61
|
+
# 3. mentioned persona is not ACP persona.
|
|
62
|
+
if not isinstance(persona, BaseAcpPersona):
|
|
63
|
+
self.finish(AcpSlashCommandsResponse().model_dump())
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Otherwise get the ACP slash commands from the persona.
|
|
67
|
+
# Convert slash commands to the response format
|
|
68
|
+
commands = []
|
|
69
|
+
for cmd in persona.acp_slash_commands:
|
|
70
|
+
name = cmd.name if cmd.name.startswith("/") else "/" + cmd.name
|
|
71
|
+
commands.append(
|
|
72
|
+
AcpSlashCommand(
|
|
73
|
+
name=name,
|
|
74
|
+
description=cmd.description
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
response = AcpSlashCommandsResponse(commands=commands)
|
|
79
|
+
self.finish(response.model_dump())
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
|
3
|
+
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
|
4
|
+
<g id="g314">
|
|
5
|
+
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
|
6
|
+
</g>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
|
2
|
+
<!-- Background -->
|
|
3
|
+
<rect width="100" height="100" rx="12" fill="#4A90D9"/>
|
|
4
|
+
|
|
5
|
+
<!-- Antenna -->
|
|
6
|
+
<line x1="50" y1="8" x2="50" y2="18" stroke="#2D3748" stroke-width="3" stroke-linecap="round"/>
|
|
7
|
+
<circle cx="50" cy="6" r="4" fill="#F6AD55"/>
|
|
8
|
+
|
|
9
|
+
<!-- Robot Head -->
|
|
10
|
+
<rect x="20" y="22" width="60" height="50" rx="8" fill="#E2E8F0"/>
|
|
11
|
+
<rect x="20" y="22" width="60" height="50" rx="8" fill="none" stroke="#2D3748" stroke-width="2"/>
|
|
12
|
+
|
|
13
|
+
<!-- Eyes -->
|
|
14
|
+
<rect x="28" y="34" width="16" height="12" rx="3" fill="#2D3748"/>
|
|
15
|
+
<rect x="56" y="34" width="16" height="12" rx="3" fill="#2D3748"/>
|
|
16
|
+
|
|
17
|
+
<!-- Eye glow -->
|
|
18
|
+
<circle cx="36" cy="40" r="4" fill="#48BB78"/>
|
|
19
|
+
<circle cx="64" cy="40" r="4" fill="#48BB78"/>
|
|
20
|
+
|
|
21
|
+
<!-- Mouth / Speaker grille -->
|
|
22
|
+
<rect x="35" y="54" width="30" height="10" rx="2" fill="#2D3748"/>
|
|
23
|
+
<line x1="40" y1="56" x2="40" y2="62" stroke="#E2E8F0" stroke-width="2"/>
|
|
24
|
+
<line x1="45" y1="56" x2="45" y2="62" stroke="#E2E8F0" stroke-width="2"/>
|
|
25
|
+
<line x1="50" y1="56" x2="50" y2="62" stroke="#E2E8F0" stroke-width="2"/>
|
|
26
|
+
<line x1="55" y1="56" x2="55" y2="62" stroke="#E2E8F0" stroke-width="2"/>
|
|
27
|
+
<line x1="60" y1="56" x2="60" y2="62" stroke="#E2E8F0" stroke-width="2"/>
|
|
28
|
+
|
|
29
|
+
<!-- Ears / Side panels -->
|
|
30
|
+
<rect x="10" y="35" width="8" height="20" rx="2" fill="#CBD5E0" stroke="#2D3748" stroke-width="1.5"/>
|
|
31
|
+
<rect x="82" y="35" width="8" height="20" rx="2" fill="#CBD5E0" stroke="#2D3748" stroke-width="1.5"/>
|
|
32
|
+
|
|
33
|
+
<!-- Body hint -->
|
|
34
|
+
<rect x="30" y="74" width="40" height="18" rx="4" fill="#E2E8F0" stroke="#2D3748" stroke-width="2"/>
|
|
35
|
+
|
|
36
|
+
<!-- Body details -->
|
|
37
|
+
<circle cx="42" cy="83" r="3" fill="#F6AD55"/>
|
|
38
|
+
<circle cx="50" cy="83" r="3" fill="#48BB78"/>
|
|
39
|
+
<circle cx="58" cy="83" r="3" fill="#FC8181"/>
|
|
40
|
+
</svg>
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Terminal manager for ACP client terminal operations."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from asyncio.subprocess import Process
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from acp import RequestError
|
|
11
|
+
from acp.schema import (
|
|
12
|
+
CreateTerminalResponse,
|
|
13
|
+
EnvVariable,
|
|
14
|
+
KillTerminalCommandResponse,
|
|
15
|
+
ReleaseTerminalResponse,
|
|
16
|
+
TerminalExitStatus,
|
|
17
|
+
TerminalOutputResponse,
|
|
18
|
+
WaitForTerminalExitResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TerminalInfo:
|
|
24
|
+
"""Tracks state for a single terminal instance."""
|
|
25
|
+
|
|
26
|
+
process: Process
|
|
27
|
+
session_id: str
|
|
28
|
+
output_buffer: bytearray = field(default_factory=bytearray)
|
|
29
|
+
output_byte_limit: int | None = None
|
|
30
|
+
truncated: bool = False
|
|
31
|
+
exit_code: int | None = None
|
|
32
|
+
exit_signal: str | None = None
|
|
33
|
+
_output_task: asyncio.Task | None = field(default=None, repr=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TerminalManager:
|
|
37
|
+
"""
|
|
38
|
+
Manages terminal lifecycle for ACP client.
|
|
39
|
+
|
|
40
|
+
Handles creation, output capture, waiting, killing, and releasing
|
|
41
|
+
of terminal processes according to the ACP terminal protocol.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, event_loop: asyncio.AbstractEventLoop):
|
|
45
|
+
"""
|
|
46
|
+
Initialize the terminal manager.
|
|
47
|
+
|
|
48
|
+
:param event_loop: The asyncio event loop for creating background tasks.
|
|
49
|
+
"""
|
|
50
|
+
self._event_loop = event_loop
|
|
51
|
+
self._terminals: dict[str, TerminalInfo] = {}
|
|
52
|
+
|
|
53
|
+
def _validate_terminal(self, terminal_id: str, session_id: str) -> TerminalInfo:
|
|
54
|
+
"""
|
|
55
|
+
Validate terminal exists and belongs to the session.
|
|
56
|
+
|
|
57
|
+
:raises RequestError: If terminal not found or belongs to different session.
|
|
58
|
+
"""
|
|
59
|
+
info = self._terminals.get(terminal_id)
|
|
60
|
+
if info is None:
|
|
61
|
+
raise RequestError.resource_not_found(terminal_id)
|
|
62
|
+
if info.session_id != session_id:
|
|
63
|
+
raise RequestError.invalid_request(
|
|
64
|
+
{"terminal_id": "terminal belongs to different session"}
|
|
65
|
+
)
|
|
66
|
+
return info
|
|
67
|
+
|
|
68
|
+
async def _read_terminal_output(self, terminal_id: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Background task to continuously read terminal output.
|
|
71
|
+
|
|
72
|
+
Reads from stdout and respects output_byte_limit with character
|
|
73
|
+
boundary truncation as required by the ACP protocol.
|
|
74
|
+
"""
|
|
75
|
+
info = self._terminals.get(terminal_id)
|
|
76
|
+
if info is None or info.process.stdout is None:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
while True:
|
|
81
|
+
chunk = await info.process.stdout.read(4096)
|
|
82
|
+
if not chunk:
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
# Check if we need to truncate
|
|
86
|
+
if info.output_byte_limit is not None:
|
|
87
|
+
current_len = len(info.output_buffer)
|
|
88
|
+
if current_len >= info.output_byte_limit:
|
|
89
|
+
# Already at limit, mark as truncated but don't add more
|
|
90
|
+
info.truncated = True
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
remaining = info.output_byte_limit - current_len
|
|
94
|
+
if len(chunk) > remaining:
|
|
95
|
+
# Need to truncate - ensure we truncate at character boundary
|
|
96
|
+
chunk = self._truncate_at_char_boundary(chunk, remaining)
|
|
97
|
+
info.truncated = True
|
|
98
|
+
|
|
99
|
+
info.output_buffer.extend(chunk)
|
|
100
|
+
|
|
101
|
+
# Process has finished, capture exit status
|
|
102
|
+
exit_code = await info.process.wait()
|
|
103
|
+
info.exit_code = exit_code
|
|
104
|
+
|
|
105
|
+
except asyncio.CancelledError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def _truncate_at_char_boundary(self, data: bytes, max_bytes: int) -> bytes:
|
|
109
|
+
"""
|
|
110
|
+
Truncate bytes at a valid UTF-8 character boundary.
|
|
111
|
+
|
|
112
|
+
The ACP protocol requires truncation at character boundaries to
|
|
113
|
+
maintain valid string output.
|
|
114
|
+
"""
|
|
115
|
+
if max_bytes <= 0:
|
|
116
|
+
return b""
|
|
117
|
+
|
|
118
|
+
truncated = data[:max_bytes]
|
|
119
|
+
|
|
120
|
+
# Walk back from the end to find a valid UTF-8 boundary
|
|
121
|
+
# UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF)
|
|
122
|
+
while truncated and (truncated[-1] & 0xC0) == 0x80:
|
|
123
|
+
truncated = truncated[:-1]
|
|
124
|
+
|
|
125
|
+
# If we're at a lead byte of a multi-byte sequence that got cut off,
|
|
126
|
+
# remove it too
|
|
127
|
+
if truncated:
|
|
128
|
+
last_byte = truncated[-1]
|
|
129
|
+
# Check if it's a multi-byte lead byte (11xxxxxx)
|
|
130
|
+
if last_byte >= 0xC0:
|
|
131
|
+
# Count expected continuation bytes
|
|
132
|
+
if last_byte >= 0xF0:
|
|
133
|
+
expected_len = 4
|
|
134
|
+
elif last_byte >= 0xE0:
|
|
135
|
+
expected_len = 3
|
|
136
|
+
elif last_byte >= 0xC0:
|
|
137
|
+
expected_len = 2
|
|
138
|
+
else:
|
|
139
|
+
expected_len = 1
|
|
140
|
+
|
|
141
|
+
# Check if the sequence is complete in the original data
|
|
142
|
+
remaining_in_truncated = 1
|
|
143
|
+
if remaining_in_truncated < expected_len:
|
|
144
|
+
# Incomplete sequence, remove the lead byte
|
|
145
|
+
truncated = truncated[:-1]
|
|
146
|
+
|
|
147
|
+
return truncated
|
|
148
|
+
|
|
149
|
+
async def create_terminal(
|
|
150
|
+
self,
|
|
151
|
+
command: str,
|
|
152
|
+
session_id: str,
|
|
153
|
+
args: list[str] | None = None,
|
|
154
|
+
cwd: str | None = None,
|
|
155
|
+
env: list[EnvVariable] | None = None,
|
|
156
|
+
output_byte_limit: int | None = None,
|
|
157
|
+
**kwargs: Any,
|
|
158
|
+
) -> CreateTerminalResponse:
|
|
159
|
+
"""
|
|
160
|
+
Create a new terminal and start executing a command.
|
|
161
|
+
|
|
162
|
+
Returns immediately with a terminal_id; the command runs in the background.
|
|
163
|
+
"""
|
|
164
|
+
# Validate command
|
|
165
|
+
if not command or not command.strip():
|
|
166
|
+
raise RequestError.invalid_params({"command": "command cannot be empty"})
|
|
167
|
+
|
|
168
|
+
# Validate cwd if provided
|
|
169
|
+
if cwd is not None:
|
|
170
|
+
if not os.path.isabs(cwd):
|
|
171
|
+
raise RequestError.invalid_params(
|
|
172
|
+
{"cwd": "cwd must be an absolute path"}
|
|
173
|
+
)
|
|
174
|
+
if not os.path.isdir(cwd):
|
|
175
|
+
raise RequestError.invalid_params(
|
|
176
|
+
{"cwd": "cwd directory does not exist"}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Build environment dict
|
|
180
|
+
env_dict = None
|
|
181
|
+
if env:
|
|
182
|
+
env_dict = os.environ.copy()
|
|
183
|
+
for e in env:
|
|
184
|
+
env_dict[e.name] = e.value
|
|
185
|
+
|
|
186
|
+
# Build command arguments
|
|
187
|
+
cmd_args = [command] + (args or [])
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
process = await asyncio.create_subprocess_exec(
|
|
191
|
+
*cmd_args,
|
|
192
|
+
cwd=cwd,
|
|
193
|
+
env=env_dict,
|
|
194
|
+
stdout=asyncio.subprocess.PIPE,
|
|
195
|
+
stderr=asyncio.subprocess.STDOUT, # Merge stderr into stdout
|
|
196
|
+
)
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
raise RequestError.invalid_params(
|
|
199
|
+
{"command": f"command not found: {command}"}
|
|
200
|
+
)
|
|
201
|
+
except PermissionError:
|
|
202
|
+
raise RequestError.invalid_params(
|
|
203
|
+
{"command": f"permission denied: {command}"}
|
|
204
|
+
)
|
|
205
|
+
except OSError as e:
|
|
206
|
+
raise RequestError.internal_error(
|
|
207
|
+
{"command": command, "error": str(e)}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
terminal_id = str(uuid.uuid4())
|
|
211
|
+
info = TerminalInfo(
|
|
212
|
+
process=process,
|
|
213
|
+
session_id=session_id,
|
|
214
|
+
output_byte_limit=output_byte_limit,
|
|
215
|
+
)
|
|
216
|
+
self._terminals[terminal_id] = info
|
|
217
|
+
|
|
218
|
+
# Start background task to read output
|
|
219
|
+
info._output_task = self._event_loop.create_task(
|
|
220
|
+
self._read_terminal_output(terminal_id)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return CreateTerminalResponse(terminal_id=terminal_id)
|
|
224
|
+
|
|
225
|
+
async def terminal_output(
|
|
226
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
227
|
+
) -> TerminalOutputResponse:
|
|
228
|
+
"""
|
|
229
|
+
Retrieve current terminal output without blocking.
|
|
230
|
+
|
|
231
|
+
Returns the captured output so far, truncation status, and exit status
|
|
232
|
+
if the command has finished.
|
|
233
|
+
"""
|
|
234
|
+
info = self._validate_terminal(terminal_id, session_id)
|
|
235
|
+
|
|
236
|
+
output = info.output_buffer.decode("utf-8", errors="replace")
|
|
237
|
+
|
|
238
|
+
# Build exit_status if process has finished
|
|
239
|
+
exit_status = None
|
|
240
|
+
if info.process.returncode is not None:
|
|
241
|
+
exit_status = TerminalExitStatus(
|
|
242
|
+
exit_code=info.exit_code,
|
|
243
|
+
signal=info.exit_signal,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return TerminalOutputResponse(
|
|
247
|
+
output=output,
|
|
248
|
+
truncated=info.truncated,
|
|
249
|
+
exit_status=exit_status,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def wait_for_terminal_exit(
|
|
253
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
254
|
+
) -> WaitForTerminalExitResponse:
|
|
255
|
+
"""
|
|
256
|
+
Block until the terminal command completes.
|
|
257
|
+
|
|
258
|
+
Returns the exit code and/or signal that terminated the process.
|
|
259
|
+
"""
|
|
260
|
+
info = self._validate_terminal(terminal_id, session_id)
|
|
261
|
+
|
|
262
|
+
# Wait for the process to complete
|
|
263
|
+
exit_code = await info.process.wait()
|
|
264
|
+
info.exit_code = exit_code
|
|
265
|
+
|
|
266
|
+
return WaitForTerminalExitResponse(
|
|
267
|
+
exit_code=info.exit_code,
|
|
268
|
+
signal=info.exit_signal,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def kill_terminal(
|
|
272
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
273
|
+
) -> KillTerminalCommandResponse | None:
|
|
274
|
+
"""
|
|
275
|
+
Terminate a running command without releasing resources.
|
|
276
|
+
|
|
277
|
+
The terminal remains valid for subsequent terminal_output and
|
|
278
|
+
wait_for_terminal_exit calls. The agent must still call
|
|
279
|
+
release_terminal afterward.
|
|
280
|
+
"""
|
|
281
|
+
info = self._validate_terminal(terminal_id, session_id)
|
|
282
|
+
|
|
283
|
+
if info.process.returncode is None:
|
|
284
|
+
# Process is still running, kill it
|
|
285
|
+
info.process.kill()
|
|
286
|
+
exit_code = await info.process.wait()
|
|
287
|
+
info.exit_code = exit_code
|
|
288
|
+
info.exit_signal = "SIGKILL"
|
|
289
|
+
|
|
290
|
+
return KillTerminalCommandResponse()
|
|
291
|
+
|
|
292
|
+
async def release_terminal(
|
|
293
|
+
self, session_id: str, terminal_id: str, **kwargs: Any
|
|
294
|
+
) -> ReleaseTerminalResponse | None:
|
|
295
|
+
"""
|
|
296
|
+
Kill any running command and deallocate all resources.
|
|
297
|
+
|
|
298
|
+
After release, the terminal_id becomes invalid.
|
|
299
|
+
"""
|
|
300
|
+
info = self._validate_terminal(terminal_id, session_id)
|
|
301
|
+
|
|
302
|
+
# Kill process if still running
|
|
303
|
+
if info.process.returncode is None:
|
|
304
|
+
info.process.kill()
|
|
305
|
+
await info.process.wait()
|
|
306
|
+
|
|
307
|
+
# Cancel the output reading task if it's still running
|
|
308
|
+
if info._output_task is not None and not info._output_task.done():
|
|
309
|
+
info._output_task.cancel()
|
|
310
|
+
try:
|
|
311
|
+
await info._output_task
|
|
312
|
+
except asyncio.CancelledError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# Remove from tracking
|
|
316
|
+
del self._terminals[terminal_id]
|
|
317
|
+
|
|
318
|
+
return ReleaseTerminalResponse()
|
|
319
|
+
|
|
320
|
+
async def cleanup_session(self, session_id: str) -> None:
|
|
321
|
+
"""
|
|
322
|
+
Clean up all terminals associated with a session.
|
|
323
|
+
|
|
324
|
+
Should be called when a session ends.
|
|
325
|
+
"""
|
|
326
|
+
terminal_ids = [
|
|
327
|
+
tid for tid, info in self._terminals.items()
|
|
328
|
+
if info.session_id == session_id
|
|
329
|
+
]
|
|
330
|
+
for terminal_id in terminal_ids:
|
|
331
|
+
try:
|
|
332
|
+
await self.release_terminal(session_id, terminal_id)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass # Best effort cleanup
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Python unit tests for jupyter_ai_acp_client."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# import json
|
|
2
|
+
|
|
3
|
+
from tornado.httpclient import HTTPClientError
|
|
4
|
+
|
|
5
|
+
async def test_slash_commands_route_no_chat(jp_fetch):
|
|
6
|
+
"""
|
|
7
|
+
Expects that the /ai/acp/slash_commands route returns a 400 when no ?chat_id
|
|
8
|
+
URL query argument is given.
|
|
9
|
+
"""
|
|
10
|
+
try:
|
|
11
|
+
await jp_fetch("ai", "acp", "slash_commands")
|
|
12
|
+
except HTTPClientError as e:
|
|
13
|
+
assert e.code == 400
|