HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
/**
* test/hal-ai-039.test.mjs — Ariweb Suite 0.3.9(개발 지시 `Ariweb-Suitle_Dev-Order_v0.3.9.md`, Oz 2026-10-29): 로그인 터미널·텍스트 셸 개선, 모델 선택, 슬래시 명령, 버그 수정.
*
* 계약(요약)
* 1) CLI 로그인: claude 의 실제 로그인 명령은 `claude auth login`(`claude login` 은 대화형 세션 시작). 셸 명령표가 이를 쓰고, 대화형 세션(bare `claude`/`codex`)도 허용 명령에 있어
* 신뢰(trust) 선택 같은 TUI 흐름을 화면에서 끝낼 수 있다. 작업 폴더는 HAL 실행 계정 HOME(이미 신뢰된 폴더).
* 2) 텍스트 셸은 원시 키 입력(화살표·Enter·Esc·Ctrl+C 등 바이트 그대로)을 받고, 단말 크기(cols·rows)를 환경으로 넘긴다. 브라우저 쪽은 순수 단말 에뮬레이터(term.mjs)로 TUI 출력을 그린다.
* 3) 세션 수 오류 수정: 끝난·종료된 세션은 한도에 세지 않고, 닫기는 세션을 끝낸다. 한도는 3.
* 4) 모델 선택: 확인된 모델 목록에서 고른다(PATCH model, 목록 밖은 거부). 방 목록이 연결의 모델 목록을 준다.
* 5) 슬래시 명령: `/help`(명령 목록, 모델 호출 없음) · `/status`·`/servers`… (모델에게 보낼 지시로 확장) · `/order <지시>` · `/model <이름>` · `/reset` · `/tools`. 모르는 명령은 400.
*/
import { test , eq , ok , tmpDir , done , sleep } from '../lib/test.mjs' ;
import { invoke } from '../lib/http.mjs' ;
import { createHalDeps } from '../hal/deps.mjs' ;
import { buildApp } from '../hal/app.mjs' ;
import * as ops from '../hal/ops.mjs' ;
import { mkdirSync , readFileSync , writeFileSync , chmodSync } from 'node:fs' ;
import { join , dirname } from 'node:path' ;
import { fileURLToPath } from 'node:url' ;
import { randomBytes } from 'node:crypto' ;
const ROOT = join ( dirname ( fileURLToPath ( import . meta . url )), '..' );
const rnd = ( p = 'fx' ) => ` ${ p } - ${ randomBytes ( 12 ). toString ( 'hex' ) } ` ;
function fakeProvider () {
const calls = []; const script = [];
const text = ( t ) => ({ id : 'msg_' + calls . length , type : 'message' , role : 'assistant' , model : 'claude-fake' , content : [{ type : 'text' , text : t }], stop_reason : 'end_turn' , usage : { input_tokens : 11 , output_tokens : 7 } });
const fetchImpl = async ( url , init = {}) => {
const u = String ( url ); const headers = Object . fromEntries ( Object . entries ( init . headers || {}). map (([ k , v ]) => [ k . toLowerCase (), v ])); let body = null ; try { body = init . body ? JSON . parse ( init . body ) : null ; } catch ( _ ) { body = init . body ; }
const rec = { url : u , method : init . method || 'GET' , headers , body }; calls . push ( rec );
const respond = ( j , status = 200 ) => ({ ok : status < 400 , status , json : async () => j , text : async () => JSON . stringify ( j ) });
if ( u . endsWith ( '/v1/models' ) && rec . method === 'GET' ) return respond ({ data : [{ id : 'claude-fake' }, { id : 'claude-fake-mini' }] });
const fn = script . shift (); const j = fn ? fn ( rec ) : text ( `응답 ${ calls . length } ` ); return respond ( j , j && j . __status ? j . __status : 200 );
};
return { fetchImpl , calls , script , text };
}
function makeHal ( fp = fakeProvider ()) {
const dataDir = join ( tmpDir (), 'hal' ); mkdirSync ( dataDir , { recursive : true });
const config = { dataDir , file : join ( dataDir , 'config.json' ), port : 0 , host : '127.0.0.1' , publicUrl : 'https://hal.example.test' , portalUrl : 'http://127.0.0.1:1' , opsAllowCidrs : [ '127.0.0.1/32' ], metricsRetentionDays : 90 , offlineAfterMs : 90000 , transferTtlMs : 3600000 , emergencyTtlMinutes : 30 , agentPollMs : 20 , agentWaitTimeoutMs : 2000 , fanoutTimeoutMs : 4000 , values : {}, set ( p ) { Object . assign ( this , p ); } };
const d = createHalDeps ({ config , dbFile : ':memory:' , fetchImpl : fp . fetchImpl });
return { d , app : buildApp ( d ), dataDir , fp };
}
async function loginOp ( h ) {
const pw = rnd ( 'pw' ); ops . createAccount ( h . d , { login : 'ozops' , password : pw , displayName : '오즈' });
const r = await invoke ( h . app , 'POST' , '/ops/api/login' , { body : { login : 'ozops' , password : pw } }); ok ( r . json . ok , JSON . stringify ( r . json ));
return { pw , as : ( method , path , body ) => invoke ( h . app , method , path , { body , cookies : { aw_ops : r . cookies . aw_ops }, headers : { 'x-csrf' : r . json . csrf } }) };
}
const texts = ( msgs ) => msgs . map (( m ) => ({ role : m . role , text : ( m . content || []). filter (( b ) => b . type === 'text' ). map (( b ) => b . text ). join ( '' ) }));
const roomOf = async ( op , id ) => ( await op . as ( 'GET' , '/ops/api/ai/rooms' )). json . rooms . find (( x ) => x . connectionId === id );
/** 가짜 CLI 설치(HOME/.local/bin). claude: auth login 은 TUI 흉내(화살표·Enter 로 고르고 코드 입력), interactive(bare)는 신뢰 질문 */
function fakeClis ( h ) {
const home = join ( h . dataDir , 'home' ); const bin = join ( home , '.local' , 'bin' ); mkdirSync ( bin , { recursive : true });
writeFileSync ( join ( bin , 'claude' ), `#!/bin/sh
if [ "$1" = "--version" ]; then echo "2.1.312 (Claude Code)"; exit 0; fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then echo '{"loggedIn": false, "authMethod": "none"}'; exit 0; fi
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then printf 'size=%sx%s cwd=%s\\r\\n' "$COLUMNS" "$LINES" "$(pwd)"; printf 'Select login method:\\r\\n> Claude account\\r\\n Console\\r\\n'; IFS= read -r k; printf 'key=%s\\r\\n' "$(printf '%s' "$k" | od -An -c | tr -s ' ')"; printf 'Paste code here: '; IFS= read -r code; printf 'Logged in with %s\\r\\n' "$code"; exit 0; fi
if [ -z "$1" ]; then printf 'Do you trust the files in this folder? (y/n) '; IFS= read -r a; printf 'trust=%s\\r\\n' "$a"; exit 0; fi
echo "unknown $@"; exit 2
` ); chmodSync ( join ( bin , 'claude' ), 0o755 );
writeFileSync ( join ( bin , 'codex' ), `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-cli 0.162.1"; exit 0; fi\nif [ "$1" = "login" ] && [ "$2" = "status" ]; then echo "Logged in using ChatGPT"; exit 0; fi\nif [ "$1" = "login" ]; then echo "open https://auth.openai.com/device"; IFS= read -r x; echo "done $x"; exit 0; fi\necho "interactive codex"; IFS= read -r x; exit 0\n` ); chmodSync ( join ( bin , 'codex' ), 0o755 );
const prev = { h : process . env . HAL_CLI_HOME , d : process . env . HAL_CLI_DIRS }; process . env . HAL_CLI_HOME = home ; process . env . HAL_CLI_DIRS = bin ;
return { home , bin , restore () { if ( prev . h === undefined ) delete process . env . HAL_CLI_HOME ; else process . env . HAL_CLI_HOME = prev . h ; if ( prev . d === undefined ) delete process . env . HAL_CLI_DIRS ; else process . env . HAL_CLI_DIRS = prev . d ; } };
}
async function waitOut ( op , id , re , n = 80 ) { let r ; for ( let i = 0 ; i < n ; i ++ ) { await sleep ( 50 ); r = await op . as ( 'GET' , `/ops/api/ai/shell/ ${ id } ?after=0` ); if ( re . test ( r . json . output )) return r . json ; } throw new Error ( `출력 대기 실패 ${ re } : ${ r && JSON . stringify ( r . json ). slice ( 0 , 400 ) } ` ); }
async function waitEnd ( op , id , n = 80 ) { let r ; for ( let i = 0 ; i < n ; i ++ ) { await sleep ( 50 ); r = await op . as ( 'GET' , `/ops/api/ai/shell/ ${ id } ?after=0` ); if ( r . json . session . status !== 'running' ) return r . json ; } throw new Error ( '종료 대기 실패' ); }
await test ( 'CLI 로그인 명령 수정: claude 는 `auth login`, 대화형 세션(bare)도 허용, 작업 폴더는 HOME; 원시 키 입력(화살표·Enter)과 단말 크기 전달' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const fx = fakeClis ( h );
try {
let r = await op . as ( 'GET' , '/ops/api/ai/shell' ); eq ( r . json . commands . claude . login , [ 'auth' , 'login' ]); eq ( r . json . commands . claude . logout , [ 'auth' , 'logout' ]); eq ( r . json . commands . claude . interactive , []); eq ( r . json . commands . codex . interactive , []); ok ( r . json . guide . claude . interactive && r . json . guide . claude . interactive . some (( g ) => / 신뢰 | trust / i . test ( g )), '대화형(신뢰 질문) 안내' );
r = await op . as ( 'GET' , '/ops/api/ai/cli' ); ok ( /claude auth login/ . test ( r . json . clis . find (( c ) => c . tool === 'claude' ). loginHint ), r . json . clis [ 0 ]. loginHint );
// auth login: 크기·작업 폴더·화살표 키·코드 입력
r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'login' , cols : 120 , rows : 40 }); eq ( r . status , 200 , JSON . stringify ( r . json )); const s = r . json . session ; eq ( s . cols , 120 ); eq ( s . rows , 40 );
let o = await waitOut ( op , s . id , /Select login method/ ); ok ( /size=120x40/ . test ( o . output ), o . output ); ok ( o . output . includes ( `cwd= ${ fx . home } ` ), '작업 폴더 = HOME' );
await op . as ( 'POST' , `/ops/api/ai/shell/ ${ s . id } /input` , { data : '\x1b[B\r' }); o = await waitOut ( op , s . id , /key=/ ); ok ( /033|\\033|\[ B/ . test ( o . output ), '화살표 바이트가 그대로 전달: ' + o . output . split ( '\n' ). find (( l ) => / key = /.test(l)));
await op . as ( 'POST' , `/ops/api/ai/shell/ ${ s . id } /input` , { data : 'CODE-9\r' }); o = await waitEnd ( op , s . id ); ok ( /Logged in with CODE-9/ . test ( o . output )); eq ( o . session . exitCode , 0 );
// 대화형: 신뢰 질문에 y 한 글자(Enter 없이도 전달) → 응답
r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'interactive' }); eq ( r . status , 200 , JSON . stringify ( r . json )); const s2 = r . json . session ; await waitOut ( op , s2 . id , /trust the files/ );
await op . as ( 'POST' , `/ops/api/ai/shell/ ${ s2 . id } /input` , { data : 'y' }); await sleep ( 80 ); await op . as ( 'POST' , `/ops/api/ai/shell/ ${ s2 . id } /input` , { data : '\r' }); o = await waitEnd ( op , s2 . id ); ok ( /trust=y/ . test ( o . output ), o . output );
// 옛 명령 이름(login 대신 'auth login' 문자열)은 거부, 임의 명령 거부
r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'auth login' }); eq ( r . status , 400 ); r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : '--dangerously-skip-permissions' }); eq ( r . status , 400 );
} finally { fx . restore (); }
});
await test ( '세션 한도 오류 수정: 끝난·종료한 세션은 한도에 세지 않고, 한도는 3, 목록은 살아 있는 것과 끝난 것을 구분한다' , async () => {
const h = makeHal (); const op = await loginOp ( h ); const fx = fakeClis ( h );
try {
const ids = []; for ( let i = 0 ; i < 3 ; i ++ ) { const r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'interactive' }); eq ( r . status , 200 , JSON . stringify ( r . json )); ids . push ( r . json . session . id ); }
let r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'status' }); eq ( r . status , 409 ); eq ( r . json . error , 'too_many_sessions' ); ok ( Array . isArray ( r . json . detail . sessions ) && r . json . detail . sessions . length === 3 , '어떤 세션이 열려 있는지 알려 준다' );
// 하나를 닫으면(DELETE) 바로 새로 열 수 있다
r = await op . as ( 'DELETE' , `/ops/api/ai/shell/ ${ ids [ 0 ] } ` ); eq ( r . status , 200 ); await waitEnd ( op , ids [ 0 ]);
r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'status' }); eq ( r . status , 200 , JSON . stringify ( r . json )); const st = r . json . session ; await waitEnd ( op , st . id );
// 스스로 끝난 세션(status 명령)은 한도에 들지 않는다 → 또 열 수 있다
r = await op . as ( 'POST' , '/ops/api/ai/shell' , { tool : 'claude' , command : 'version' }); eq ( r . status , 200 , JSON . stringify ( r . json )); await waitEnd ( op , r . json . session . id );
r = await op . as ( 'GET' , '/ops/api/ai/shell' ); const running = r . json . sessions . filter (( x ) => x . status === 'running' ); eq ( running . length , 2 ); ok ( r . json . sessions . some (( x ) => x . status !== 'running' ), '끝난 세션도 목록에 보인다(상태 구분)' );
// 모두 종료
r = await op . as ( 'POST' , '/ops/api/ai/shell/kill-all' ); eq ( r . status , 200 ); for ( const id of ids . slice ( 1 )) await waitEnd ( op , id ); r = await op . as ( 'GET' , '/ops/api/ai/shell' ); eq ( r . json . sessions . filter (( x ) => x . status === 'running' ). length , 0 );
} finally { fx . restore (); }
});
await test ( '단말 에뮬레이터(순수): 커서 이동·지우기·줄 바꿈·CR 덮어쓰기·대체 화면·스크롤·SGR 무시·OSC 무시' , async () => {
const T = await import ( '../hal/ui/public/term.mjs' ); const t = new T . Term ( 20 , 4 );
t . feed ( 'hello\r\nworld' ); eq ( t . lines (). slice ( 0 , 2 ), [ 'hello' , 'world' ]); eq ( t . cursor (), { row : 1 , col : 5 });
t . feed ( '\rWORLD' ); eq ( t . lines ()[ 1 ], 'WORLD' ); // CR 덮어쓰기(진행 표시줄)
t . feed ( '\x1b[1;1H\x1b[2K> Claude account' ); eq ( t . lines ()[ 0 ], '> Claude account' ); // 커서 이동 + 줄 지우기
t . feed ( '\x1b[2;3H\x1b[31mX\x1b[0m' ); eq ( t . lines ()[ 1 ], 'WOXLD' ); // 색(SGR)은 무시하고 글자만
t . feed ( '\x1b]0;title\x07\x1b[?25l' ); eq ( t . lines ()[ 0 ], '> Claude account' ); // OSC·커서 숨김 무시
t . feed ( '\x1b[2J\x1b[H' ); eq ( t . lines (). join ( '' ). trim (), '' ); eq ( t . cursor (), { row : 0 , col : 0 }); // 화면 지우기
t . feed ( 'a\r\nb\r\nc\r\nd\r\ne' ); eq ( t . lines (), [ 'b' , 'c' , 'd' , 'e' ]); ok ( t . scrollback (). includes ( 'a' ), '밀려난 줄은 스크롤백에' ); // 스크롤
t . feed ( '\x1b[?1049h' ); eq ( t . lines (). join ( '' ). trim (), '' , '대체 화면은 빈 화면' ); t . feed ( 'alt' ); t . feed ( '\x1b[?1049l' ); eq ( t . lines (), [ 'b' , 'c' , 'd' , 'e' ], '대체 화면 종료 뒤 복원' );
t . feed ( '\x1b[4;1H\x1b[3C\x1b[Dz' ); eq ( t . cursor (). col , 3 ); eq ( t . lines ()[ 3 ], 'e z' ); // 4행 1열 → 오른쪽 3, 왼쪽 1 → 열 2 에 z 쓰고 3
const html = T . renderHtml ( t ); ok ( html . includes ( '<' ) === false && typeof html === 'string' ); const t2 = new T . Term ( 10 , 2 ); t2 . feed ( '<b>&' ); ok ( T . renderHtml ( t2 ). includes ( '<b>&' ), '이스케이프' );
const t3 = new T . Term ( 5 , 2 ); t3 . feed ( 'abcdefgh' ); eq ( t3 . lines (), [ 'abcde' , 'fgh' ], '줄 끝에서 자동 줄 바꿈' );
});
await test ( '모델 선택: 확인된 목록에서 고른다(PATCH·/model 명령), 목록 밖은 거부, 방 목록이 모델 목록을 준다' , async () => {
const h = makeHal (); const op = await loginOp ( h );
let r = await op . as ( 'POST' , '/ops/api/ai/connections' , { name : '네오' , provider : 'anthropic' , authKind : 'api_key' , apiKey : rnd ( 'sk' ), model : 'claude-fake' }); const c = r . json . connection ;
await op . as ( 'POST' , `/ops/api/ai/connections/ ${ c . id } /test` );
r = await op . as ( 'GET' , '/ops/api/ai/rooms' ); const room = r . json . rooms . find (( x ) => x . connectionId === c . id ); eq ( room . connection . models , [ 'claude-fake' , 'claude-fake-mini' ]); eq ( room . connection . model , 'claude-fake' );
r = await op . as ( 'PATCH' , `/ops/api/ai/connections/ ${ c . id } ` , { model : 'claude-fake-mini' }); eq ( r . status , 200 ); eq ( r . json . connection . model , 'claude-fake-mini' );
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/model claude-fake' }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . responders , []); await h . d . ai . drain ();
eq (( await op . as ( 'GET' , '/ops/api/ai/connections' )). json . connections [ 0 ]. model , 'claude-fake' ); eq ( h . fp . calls . filter (( x ) => x . method === 'POST' ). length , 0 , '모델 호출 없음' );
r = await op . as ( 'GET' , `/ops/api/ai/rooms/ ${ room . id } /messages` ); ok ( r . json . messages . some (( m ) => m . role === 'system' && /claude-fake/ . test ( texts ([ m ])[ 0 ]. text )), '전환 안내' );
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/model nope-model' }); eq ( r . status , 400 ); eq ( r . json . error , 'model_unavailable' );
r = await op . as ( 'POST' , '/ops/api/ai/rooms/room_group/messages' , { text : '/model claude-fake' }); eq ( r . status , 400 ); eq ( r . json . error , 'command_needs_direct_room' );
});
await test ( '슬래시 명령: /help 는 모델 호출 없이 명령 목록, /status·/servers 는 지시로 확장되어 모델에게, /order 는 작업 지시 틀, /tools·/reset 은 즉시, 모르는 명령은 400' , async () => {
const h = makeHal (); const op = await loginOp ( h );
let r = await op . as ( 'POST' , '/ops/api/ai/connections' , { name : '네오' , provider : 'anthropic' , authKind : 'api_key' , apiKey : rnd ( 'sk' ), model : 'claude-fake' }); const c = r . json . connection ; const room = await roomOf ( op , c . id );
r = await op . as ( 'GET' , '/ops/api/ai/commands' ); eq ( r . status , 200 ); const names = r . json . commands . map (( x ) => x . name ); for ( const n of [ 'help' , 'status' , 'servers' , 'jobs' , 'releases' , 'incidents' , 'order' , 'model' , 'reset' , 'tools' , 'deploy' , 'rollback' ]) ok ( names . includes ( n ), '명령 ' + n ); ok ( r . json . commands . every (( x ) => x . usage && x . description ));
// /help: 모델 호출 없음, 시스템 메시지에 명령 목록
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/help' }); eq ( r . status , 200 , JSON . stringify ( r . json )); eq ( r . json . responders , []); await h . d . ai . drain (); eq ( h . fp . calls . filter (( x ) => x . method === 'POST' ). length , 0 );
r = await op . as ( 'GET' , `/ops/api/ai/rooms/ ${ room . id } /messages` ); const help = r . json . messages . find (( m ) => m . role === 'system' ); ok ( help && /\/order/ . test ( texts ([ help ])[ 0 ]. text ) && /\/status/ . test ( texts ([ help ])[ 0 ]. text ), '명령 목록' );
// /status → 모델에게는 확장된 지시(hal_overview 사용 지시), 화면에는 원문 유지
h . fp . script . push (( req ) => { const last = req . body . messages [ req . body . messages . length - 1 ]; const t = JSON . stringify ( last . content ); ok ( /hal_overview/ . test ( t ) && /운영 현황/ . test ( t ), '확장 지시: ' + t . slice ( 0 , 200 )); return h . fp . text ( '현황 요약' ); });
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/status' }); eq ( r . json . responders , [ c . id ]); await h . d . ai . drain ();
r = await op . as ( 'GET' , `/ops/api/ai/rooms/ ${ room . id } /messages` ); const um = r . json . messages . filter (( m ) => m . role === 'user' ). pop (); eq ( texts ([ um ])[ 0 ]. text , '/status' ); ok ( um . content . some (( b ) => b . type === 'command' && b . name === 'status' ), '명령 블록 기록' );
// /servers (인자 전달) · /order
h . fp . script . push (( req ) => { const t = JSON . stringify ( req . body . messages [ req . body . messages . length - 1 ]. content ); ok ( /hal_servers_list/ . test ( t ) && /web/ . test ( t ), t . slice ( 0 , 200 )); return h . fp . text ( 'ok' ); });
await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/servers web' }); await h . d . ai . drain ();
h . fp . script . push (( req ) => { const t = JSON . stringify ( req . body . messages [ req . body . messages . length - 1 ]. content ); ok ( /디스크 점검/ . test ( t ) && /계획/ . test ( t ) && /승인/ . test ( t ), t . slice ( 0 , 300 )); return h . fp . text ( '계획 세움' ); });
await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/order 웹1 디스크 점검' }); await h . d . ai . drain ();
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/order' }); eq ( r . status , 400 ); eq ( r . json . error , 'command_args_required' );
// /deploy 인자 → 고위험 도구 제안 지시
h . fp . script . push (( req ) => { const t = JSON . stringify ( req . body . messages [ req . body . messages . length - 1 ]. content ); ok ( /hal_module_deploy/ . test ( t ) && /portal/ . test ( t ) && /0\.3\.9/ . test ( t ), t . slice ( 0 , 300 )); return h . fp . text ( '제안' ); });
await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/deploy portal 0.3.9' }); await h . d . ai . drain ();
// /tools: 즉시(모델 호출 없음) · /reset · 모르는 명령
const before = h . fp . calls . length ; r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/tools' }); eq ( r . json . responders , []); eq ( h . fp . calls . length , before );
r = await op . as ( 'GET' , `/ops/api/ai/rooms/ ${ room . id } /messages` ); ok ( /hal_health/ . test ( texts ([ r . json . messages . pop ()])[ 0 ]. text ));
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/reset' }); eq ( r . status , 200 ); r = await op . as ( 'GET' , `/ops/api/ai/rooms/ ${ room . id } /messages` ); eq ( r . json . messages . length , 0 );
r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '/nope x' }); eq ( r . status , 400 ); eq ( r . json . error , 'unknown_command' ); ok ( /\/help/ . test ( r . json . message ));
// 통합 방에서도 /help 동작, 슬래시로 시작하지 않는 글은 그대로
r = await op . as ( 'POST' , '/ops/api/ai/rooms/room_group/messages' , { text : '/help' }); eq ( r . status , 200 ); eq ( r . json . responders , []);
h . fp . script . push (() => h . fp . text ( 'ok' )); r = await op . as ( 'POST' , `/ops/api/ai/rooms/ ${ room . id } /messages` , { text : '일반 지시 /status 아님' }); eq ( r . json . responders , [ c . id ]); await h . d . ai . drain ();
});
await test ( '화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에뮬레이터·세션 목록, 모델 선택 UI, /help 안내, 패키지 0.3.9, 개발 지시 v0.3.9' , async () => {
const h = makeHal (); const op = await loginOp ( h );
const r = await op . as ( 'GET' , '/ops/ai' ); eq ( r . status , 200 ); for ( const s of [ 'claude auth login' , '텍스트 셸' , '대화형' ]) ok ( r . text . includes ( s ), '화면 문구 ' + s );
const p = await op . as ( 'GET' , '/ops/' ); ok ( p . text . includes ( '/help' ), '패널 입력 안내에 /help' );
eq (( await invoke ( h . app , 'GET' , '/ops/ui/term.mjs' )). status , 200 );
const AR = await import ( '../hal/ui/public/ai-render.mjs' ); ok ( typeof AR . modelSelectHtml === 'function' && typeof AR . shellSessionsHtml === 'function' && typeof AR . commandsHelpHtml === 'function' );
ok ( AR . modelSelectHtml ({ id : 'c1' , model : 'a' , models : [ 'a' , 'b<' ] }). includes ( 'b<' ) && AR . modelSelectHtml ({ id : 'c1' , model : 'a' , models : [] }). includes ( '확인' ));
2026-10-29 02:49:46 +00:00
eq ( JSON . parse ( readFileSync ( join ( ROOT , 'package.json' ), 'utf8' )). version , '0.3.10' ); eq ( h . d . version , '0.3.10' );
HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과
2026-10-29 02:22:21 +00:00
ok ( readFileSync ( join ( ROOT , 'Ariweb-Suitle_Dev-Order_v0.3.9.md' ), 'utf8' ). includes ( 'Oz' )); ok ( readFileSync ( join ( ROOT , 'VERSION_HISTORY.md' ), 'utf8' ). includes ( '## 0.3.9' ));
});
done ();